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

Parameter-Efficient Fine-Tuning (PEFT)

Classroom material · Next in class: Lesson 8.7

43 min · 19 code cells · 1 labs

In this lesson
Time
~43 min
Sections
12
Code cells
19
Labs
1
Figures
4

Before you start

Can we adapt a model just as well while training only a tiny fraction of its parameters?

You will be able to
  • account for the memory full fine-tuning needs: weights, gradients and optimiser states;
  • freeze parameters and explain what that saves;
  • explain the core idea of PEFT, and the evidence that fine-tuning updates are approximately low-rank;
  • build a LoRA layer and explain rank, alpha and the choice of target layers;
  • fine-tune with LoRA using Hugging Face PEFT, and inspect, save, reload and merge the result;
  • compare LoRA with full fine-tuning on accuracy, cost and forgetting.

In Lesson 8.4 we fine-tuned every one of DistilBERT's 66,955,010 weights. It worked, but it was expensive in ways that grow painfully with model size. Training needed memory for every weight's gradient and optimiser state. The result was a complete copy of the model, hundreds of megabytes, for one task. And the model lost some of its original skill along the way. For a model with billions of parameters, full fine-tuning needs a large GPU cluster, and every task needs its own multi-gigabyte copy.

Parameter-efficient fine-tuning (PEFT) asks: what if we leave the pretrained weights exactly as they are, and train only a small number of extra ones? This lesson builds up to the most widely used PEFT method, LoRA (low-rank adaptation). We start from what makes full fine-tuning expensive, measure evidence that the changes fine-tuning makes have a simple structure, build a LoRA layer by hand, then use Hugging Face's PEFT library to fine-tune DistilBERT on the same 1,000 reviews as Lesson 8.4, and compare the two on the same test reviews.

Key question: Can we adapt a model just as well while training only a tiny fraction of its parameters?

Prerequisites

read Lesson 8.4; this lesson follows the same recipe and compares against its saved results. You'll also use matrix multiplication and shapes (Module 2), AdamW (Lesson 8.4), and the forgetting measure (Lesson 8.1).

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, including the LoRA-related messages that the PEFT library prints. Training takes under a minute on a fast laptop CPU (Section 8).

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.

Comparison artifacts. Missing baseline result files are restored from bundled, clearly labelled author reference data. Existing result files are preserved. Run 8.3–8.5 in the same working folder to replace these with your own experiments. The encoded reference payload in setup is delivery data; you do not need to decipher it to study the model code.

Setup 56 lines
import os, json, time, copy, math, shutil, hashlibimport numpy as npimport torchimport torch.nn as nnimport matplotlib.pyplot as pltfrom scipy.stats import binomtestfrom datasets import load_datasetfrom huggingface_hub import get_safetensors_metadatafrom transformers import (AutoTokenizer, AutoModelForSequenceClassification, AutoModelForMaskedLM,                          DataCollatorWithPadding, get_linear_schedule_with_warmup)from peft import LoraConfig, get_peft_model, PeftModeltorch.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()# Author reference results are embedded for standalone downloads, never fetched secretly.REFERENCE_RESULTS_GZIP = 'H4sIAAAAAAAC/+1bbW/bOBL+K4I/7aGJb4YvEpVFP9zt3gIH3KfdfisKQ3HkxFe/nay01y3y35ccWY4tzVByWizugEBoStMUhxy+Pc8z9NeJm+rJzdfJZluXkxv6dDXZ71bLerZYbu7LalctN/U+FKmrYrnxZRa3ukCl8kVu0Rf+VKxmpf/jv8lzbRaLsijmefimLvd1+9Vini+K3GWAt/PJ01VT2Wz/eLsvzyz5otktzAsLiP4/X0v1uLkn++vi39tqWX9J5qtiTzlU9ddJMZ8/VsX8y+QGphasf6WcF6vVbFPeT25wCseM3XYfyviMBR4/PDUN7VZk3AsqWhW35Wo/e9yXd/4FADg4YVeFjPdw9fq8Pq/P6/P6vD6vz//588Gf+O9+uf7nz79cJXjlT/ukOf8FaOLS9BxRhKxcd0GFS1V6iitcaoyAUZxyvRoVpL0atT2rUGv91Acm6J/nv3D4i8fu4tm/0++w9/k09/R7PMkF5juprn47ztuJZ/bgzEbfIpy0DJgeYbT/wNTX9wP2bPTbyPen7yupRLcf3fqwNxLQGzXs+aTbUzwb+W5/oOP1buu7b0vzB5hec77nesD5i289Z/XcT9K4oOiXvmf6be36BNgV0e251FKM+DG2hrm1B2x7QBwBfsy4OcrtGtBbX9yaA2YvQGY8JJ8AM6tRGAlpD4uNH7C7GfbWGbL1Itu6WJ8hMkuwt44hMsIojl9330R2lqHQcxDHE3rrq7t/SiXk8eH2tf6ezZ8A8g6OvXHn5y6IZwdGdyZux4Veb1E4Lbn1CMyu0gATXokIkkt5N9sVVbEu67LyaEDlmbWtGlMv10EDgikm+6AClXW93HhUMfnprZ6cYB0PLeJIJ1fYxSU5OtfFJbnS6hSY5AokpJOj7teY95BODqk7qxGNi0IdHDH4KBw5MHiYSFMYWSgEkW2/v7ik5ctPTm4j45YNN6kvhTr9XGSnqryRwsDWw/kcxS34EqgjbSFxqAMRqCOBrjg84noAEajT3+T4g56HOhJ4GII6IGyjKGxeyMy3GNTBAfgk9xqFLZ7floehTt8ODkIe6K0i+aDkoT4yM0CiJMAABGAPFhDBAog943cynhZx+wa/M/EwHQUg/GdAHc6nQ1CHm4089QKWjnxvqMP5ZSzUgcjuhSKNRYHGx85PZCE9smfNBVBHKQOGxzo6z43FDtgxU9MDOwgB7fz227trlay3d+XqKtkuFkn9UCb7h3K1kPQd11djXI49Ncal+Zkc4z9L+k7WQz3O2qxfY1CRTmrMnBJQDzJ4ASPCiizwxIAvd/pAZOUCczJx5BQYcQAjAg8OCjw8eZQRkrwTYYTMS6gHWNHi21GPJPCAiA6BlQxQpKHIID9kdgsJ9ch2gB0PEBEzslIDsLMGIoIcChiZf1Oi+cDOY36+yeIiPx9hJJGUcJ8sxvIoZmh34BAgRmUsEEaclznkUZJ2Cp5hyagHhVrGoB4U9xh+nkFULkJBlJIYFgj4HFmkEcuNISaJ5UIU48joW94VYQS6x6gMP7QrAHtKgMA6L0E9GEE9GEE98ALUc/imi3oEdcdnLzeLsio384B1co911vtkV1ZJVX5alp/PcM9DWdy9qYulRzuoXFJvP5abfQBCi2r7e7lJfl7u6+Xq7//49V3yJllt78PHua/ovir3++V2I6Ei24cwuelBGEJtJxDGZFoCRTrr1ahtH2Zp585rRB0BRWN1wKGolzRRMRIRkyczCouHF26Go17wQikIBqEhd1zDiCgQRhca3zsplvYyUCQt6TGgSI56ST6X35MjR5LUKEUtZKguCSz8cSNLnyi2SxpHFCK4MCCa8aAILwRFIESLZdkW2LWArAAjxQ1R8DwvcsryniSkShFHHC0FISsY8IQIBC8jS134PQYEMQujImtcNpYBQFwKkmCYdC+Ap4WxGCxGaINMOoelIBgFisaDMmBl/XGgSBovGA2KeFLPg04OTj1fxxkd9cqCAnMmA+VT3ZGB1mWxSXbb7cp/vEqO4Chgo6vkp7c4eXrycMJNzdntaePr8FYetnfhzvPjapUsvPXr+nHjq0l+mD/u6+3ao6ft7iqZP2z3HlfNH8r5x912uan/8r929frgjeb2dRDCfJE7goG3ZVX/9Tl5fVvsy+vHzbwInvcuKP47W5Wb+/rBD0O4pFRX4cuaYOIz0vQly912/uANePx2W9Tzh9l++bt3JaZ+MMuiCm6bVUXwri2vw93uz0W1ftwFROdfua+Ku9l8tdwdrmc3Lp3t69LnUOhPmhHF7ao8mxNpmlvfc+hMjLSJhVJeaMu6XG+rLzPv3uXaN2u2vg3VZnjF3/gSMazpYdjMqC6GzfIUz29uYTqAYWHw5haINzGk8y4ubfOoAAYCK8jugWNvbsmSfQzDysiPP9HidBgjGBbFu0cY3T0hIjnIGBa+SdiDEcKeHLoZDkpCNFw+RtiD6M0tKYgu38+6TNiTAqRjwpmS90EU4HBAQOX4DoiCJwprGgcwLM9E+ZCkdO+HQ1Ag3CwEAVePDWditMVjhD1gg5GyvAUjBDm+PpkFSYG7MeFMaQ7GhL3xGHYo8AuDtzDHhDO58ZTD8t8Dw4IgnGJEXAbxru2QsCfpRyf5HsMuttV9g7tmn7fV3ez+Mehr/tNq2/zqzZ/8B1A7udFT7QJEOIDMkGWmkB1RXnhn5nHHJ49l3r/3Jqapy9yHq/cqJHPMfVL7pAeHIWmaAiFpKVeHZEplHfpkFpJZbnzSNWV9Kg9FkTI9fgpllQppMpdmlCZ7BigdDKaodUiTxUyHujGYtGmoEcmk0jakg82MKieTGYb2YzBqNaShL8GopVYrshkKK7LoUV1Ia6qZXlTBonUQzCiyiFRHsGicCRZVsGiNCg1Ujoo7SgebxsPv4DSy6bLQcB2MWpWFd7WiMobKBLMGyawOZpX3eEiTWd2USakeTWWCXY0YnKSDXZM1ZaivmIcmm2BX62a0yG5G7TTBrs7JY4a6m1PSUDUuVG+CWZWTgw11l6yajFpGPTeOaqckGc2zULkNRo0NbbfBps6o35Zsogr+s42LgfKpr1mwY4NNA5aKBJvaAM2ujPJd6Kolo5ml/GA1BbKa0lzKHE3BYFaZUE2qqBv0akpWtU4/fGjY0DOZ2tOEp24+D4wzbSddatv3w6B/aIlidZCxZ+dx+O6tAedZQcP/7Bn/s6f871/bX/+W/FC9deHK5O6heOubk/xn5hll8ib5FP7/kYhlcljOr/wvxv9gCoAD/K8qNh8nN94e+bup5hs4YaZz69Lu2NthRqicr3NffCrvqD+Up0MD2WsjF5BE5fLBQId2buTtj/GBDhCv9F9CEkH8yUr85z0vC3QM3/64hCRKoiYvjwNLKWW4FCOJKN6FgAsDHTLwGU8SIXI/Uwp0jCOJ8UDHUJwZIwEXKcjH0y58AUlE8Wb2pSQx9pOQS0kiivMSo97DC25/SHRsmCTKgdI4SeTvYEl37+VAx8vvvMZ+iATC7JWEE/n2h7zXSD+did/+wBG3P/BCkjhWuhkX6Ijf/sBRP8IcG+j4Xrc/QLhRDheu5LhE1A10vIQkEhxdl/7FuySgST01KcaYYq5OmKI7MkUC5wemaI9MEXXLFF1+ZIqWgH9D28wzVXT2SBUtsRdsmAxmLVX0BA1bqmgPPLAhbsa0VNE4InqE8S0QIyGu6IketmzRpFq1bNHY/MgWjbWqpYs6ta4ljJZMEV/ELFUtXzQNuSQClRnd8kXlQXfLF32RrOWLJlXmmS+iafmizpo0eVcfWYlWRKGILvrqj3QRjcpauuj5EbZ0MTNWt3RRGaLoRBc9icOWLqZAg9fQRSSaTnTR9zVv6SI66qFpSJB+5otIrNkc7B75okZyMhFGnbqWSqksxSNhtDRRGsLYMF9ijMbhM2Mk2kmE0eRgjoSx4Z0NY2ySacOlXUsYtWlIJfkYSZAgwmgVdaQhjLZhiUQYieWnDTU+IYzGRgkjDXbDF+2RFZMLG77oX356+gOJoYQCOkkAAA=='def ensure_comparison_results(notes):    """Restore missing author reference artifacts while preserving existing learner-generated results.    Arguments:        notes: Lesson IDs whose result files are required.    """    import base64, gzip, json, pathlib, hashlib    reference = json.loads(gzip.decompress(base64.b64decode(REFERENCE_RESULTS_GZIP)))    folder = pathlib.Path('results'); folder.mkdir(exist_ok=True)    for note in notes:        target = folder / (note + '.json')        if not target.exists():            target.write_text(json.dumps(reference[note], indent=1))            print('Using bundled AUTHOR REFERENCE results for', note, '(not a new training run).')
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 20:22:44.600000 99824 site-packages/torch/distributed/elastic/multiprocessing/redirects.py:35] NOTE: Redirects are currently not supported in MacOs.
W1017 20:22:44.687000 99824 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 20:22:44.707000 99824 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 a Model While Training Fewer Parameters

Full fine-tuning changes every weight W of the pretrained model to W + ΔW, where ΔW (delta W) is whatever training adds. PEFT keeps W frozen and learns only a compact replacement for ΔW, or a few small extra pieces of network. The pretrained model is then shared: one copy of the large weights, plus a small, swappable piece for each task.

The rest of this lesson answers four questions:

  1. Why is full fine-tuning so expensive? (Section 2)
  2. Why can a small number of trained parameters be enough? (Sections 3–4)
  3. How does LoRA work, and what are its settings? (Sections 5–7)
  4. Does it work as well, here? (Sections 8–9)
Full Fine-Tuning vs. LoRA. LoRA adds (alpha/r) BA beside a frozen weight matrix. A starts random and B starts at zero, so the initial adapter update is zero; the task head may also train.

Figure 8.5.1. LoRA adds (alpha/r) BA beside a frozen weight matrix. A starts random and B starts at zero, so the initial adapter update is zero; the task head may also train.

The data

The same fixed split, the same 1,000 training reviews (checked by fingerprint), head+tail truncation to 128 tokens, and the same helpers as Lesson 8.4. We load the starting model once and copy it for each experiment below.

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 7 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)}")train_x, train_y = imdb["train"][0][:1000], imdb["train"][1][:1000]     # the 1,000 labelled reviews of Module 8val_x, val_y = imdb["val_eval"]test_x, test_y = imdb["test_eval"]print("Module 8 training set fingerprint:", fingerprint(train_x))
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.
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
Module 8 training set fingerprint: 7b0ca50110ca
Python 51 lines
BASE = "distilbert/distilbert-base-uncased"tok = AutoTokenizer.from_pretrained(BASE, revision=MODEL_REVISIONS.get(BASE))def head_tail_ids(texts, max_length=128):    """Produce unpadded review token IDs with special tokens and the shared head-and-tail policy.    Arguments:        texts: Text strings in the order expected by the caller.        max_length: Token budget including special tokens.    """    keep = max_length - 2    out = []    for ids in tok(texts, add_special_tokens=False)["input_ids"]:        if len(ids) > keep:            ids = ids[:keep // 4] + ids[-(keep - keep // 4):]        out.append([tok.cls_token_id] + ids + [tok.sep_token_id])    return outcollate = DataCollatorWithPadding(tok)def make_batch(ids_list, labels=None):    """Use the padding collator to construct a device-ready input and optional-label batch.    Arguments:        ids_list: Unpadded integer token-ID sequences.        labels: Integer targets aligned with the input examples.    """    rows = [{"input_ids": ids} for ids in ids_list] if labels is None else \           [{"input_ids": ids, "labels": int(y)} for ids, y in zip(ids_list, labels)]    return {k: v.to(device) for k, v in collate(rows).items()}def predict(m, ids_list, batch_size=64):    """Run the declared model or service and return predictions in the original example order.    Arguments:        m: Model or module being inspected or evaluated.        ids_list: Unpadded integer token-ID sequences.        batch_size: Maximum examples processed together.    """    m.eval()    order = np.argsort([len(x) for x in ids_list])    probs = np.empty(len(ids_list))    with torch.inference_mode():        for i in range(0, len(ids_list), batch_size):            idx = order[i:i + batch_size]            probs[idx] = m(**make_batch([ids_list[j] for j in idx])).logits.softmax(-1)[:, 1].cpu().numpy()    return probstrain_ids, val_ids, test_ids = head_tail_ids(train_x), head_tail_ids(val_x), head_tail_ids(test_x)LABELS = dict(num_labels=2, id2label={0: "NEGATIVE", 1: "POSITIVE"}, label2id={"NEGATIVE": 0, "POSITIVE": 1})torch.manual_seed(0)start = AutoModelForSequenceClassification.from_pretrained(BASE, **LABELS, revision=MODEL_REVISIONS.get(BASE))   # loaded once; copied for each experiment
Warnings / log
[transformers] Token indices sequence length is longer than the specified maximum sequence length for this model (544 > 512). Running this sequence through the model will result in indexing errors
Loading weights:   0%|          | 0/100 [00:00<?, ?it/s]Loading weights: 100%|██████████| 100/100 [00:00<00:00, 22665.79it/s]
[transformers] DistilBertForSequenceClassification LOAD REPORT from: distilbert/distilbert-base-uncased
Key                     | Status     | 
------------------------+------------+-
vocab_projector.bias    | UNEXPECTED | 
vocab_transform.bias    | UNEXPECTED | 
vocab_layer_norm.weight | UNEXPECTED | 
vocab_transform.weight  | UNEXPECTED | 
vocab_layer_norm.bias   | UNEXPECTED | 
classifier.bias         | MISSING    | 
classifier.weight       | MISSING    | 
pre_classifier.bias     | MISSING    | 
pre_classifier.weight   | MISSING    | 

Notes:
- UNEXPECTED:	can be ignored when loading from different task/architecture; not ok if you expect identical arch.
- MISSING:	those params were newly initialized because missing from the checkpoint. Consider training on your downstream task.
Checkpoint Before you move on from §1

What does parameter-efficient adaptation aim to reduce?

2

Why Full Fine-Tuning Is Expensive: Weights, Gradients and Optimizer States

During training, memory is needed for more than the weights. Every trainable weight also needs a gradient of the same size, and the AdamW optimiser keeps two optimiser states per weight: a running average of the gradients and a running average of their squares. Let's measure all three after one full fine-tuning step on DistilBERT:

Python 12 lines
full = copy.deepcopy(start).to(device)opt = torch.optim.AdamW(full.parameters(), lr=5e-5)full.train()full(**make_batch(train_ids[:16], train_y[:16])).loss.backward()opt.step()mb = lambda tensors: sum(t.numel() * t.element_size() for t in tensors) / 1e6weights = mb(full.parameters())grads = mb(p.grad for p in full.parameters())adam = mb(v for state in opt.state.values() for v in state.values() if torch.is_tensor(v) and v.dim() > 0)print(f"full fine-tuning of DistilBERT, float32: weights {weights:.1f} MB | gradients {grads:.1f} MB | "      f"Adam states {adam:.1f} MB | total {weights + grads + adam:.1f} MB (before activations)")del full, opt
Output
full fine-tuning of DistilBERT, float32: weights 267.8 MB | gradients 267.8 MB | Adam states 535.6 MB | total 1071.3 MB (before activations)

In float32 the weights take 267.8 MB, the gradients another 267.8 MB, and Adam's states 535.6 MB, twice as much. That is 1071.3 MB in total, four times the model's size, before counting the activations a batch needs. The rule of thumb: full fine-tuning with Adam needs about 16 bytes per parameter in float32 (4 for the weight, 4 for its gradient, 8 for the two states).

For a small model that's manageable. For a modern large model it's not:

Python 5 lines
n7b = sum(get_safetensors_metadata("Qwen/Qwen2.5-7B").parameter_count.values())   # header only (Lesson 7.3)print(f"a 7B model: {n7b:,} parameters")for label, per_param in [("weights only (inference, bfloat16)", 2),                         ("full fine-tuning, float32: weights + gradients + 2 Adam states", 4 * 4)]:    print(f"  {label:<62} {n7b * per_param / 1e9:7.1f} GB")
Warnings / log
Parse safetensors files:   0%|          | 0/4 [00:00<?, ?it/s]Parse safetensors files:  25%|██▌       | 1/4 [00:02<00:08,  2.68s/it]Parse safetensors files:  75%|███████▌  | 3/4 [00:03<00:00,  1.14it/s]Parse safetensors files: 100%|██████████| 4/4 [00:03<00:00,  1.63it/s]Parse safetensors files: 100%|██████████| 4/4 [00:03<00:00,  1.22it/s]
Output
a 7B model: 7,615,616,512 parameters
  weights only (inference, bfloat16)                                15.2 GB
  full fine-tuning, float32: weights + gradients + 2 Adam states   121.8 GB

A 7-billion-parameter model (here Qwen2.5-7B, counted from its file header as in Lesson 7.3) needs 15.2 GB just to run in bfloat16. Fully fine-tuning it in float32 needs about 121.8 GB for weights, gradients and Adam states, far beyond any single consumer GPU. (Mixed-precision training reduces this somewhat, but not by enough to change the conclusion.) And each fine-tuned copy must be stored and served separately.

Key terms

  • Optimiser state: extra numbers an optimiser keeps for each trainable weight; AdamW keeps two (running averages of the gradient and of its square).
  • Training memory: weights + gradients + optimiser states + activations; about 16 bytes per trainable parameter in float32 with Adam, before activations.
Check your understandingCheck yourself
  1. If only 1% of a model's parameters are trainable, which parts of the training memory shrink, and which don't?
Checkpoint Before you move on from §2

What contributes to Adam training-state memory?

3

Frozen vs. Trainable Parameters

The simplest way to save memory is to freeze parameters: set requires_grad = False, so PyTorch computes no gradient for them and the optimiser keeps no state for them. Here we freeze DistilBERT's whole body and train only the head, the "head only" end of the spectrum:

Python 10 lines
m = copy.deepcopy(start).to(device)for name, p in m.named_parameters():    p.requires_grad = not name.startswith("distilbert.")              # freeze the body, train only the headm.train()m(**make_batch(train_ids[:16], train_y[:16])).loss.backward()trainable = [(n, p) for n, p in m.named_parameters() if p.requires_grad]print(f"trainable: {sum(p.numel() for _, p in trainable):,} of {sum(p.numel() for p in m.parameters()):,}")print("a frozen weight's gradient:", m.distilbert.transformer.layer[0].attention.q_lin.weight.grad)print("a trainable weight's gradient shape:", tuple(m.classifier.weight.grad.shape))del m
Output
trainable: 592,130 of 66,955,010
a frozen weight's gradient: None
a trainable weight's gradient shape: (2, 768)

Only 592,130 of the 66,955,010 parameters are trainable now. After a backward pass, a frozen weight's gradient is None: none was computed, and none is stored. The head's gradient exists, with the shape of the head's weights. Frozen parameters still take memory as weights, and still take part in the forward pass, but they need no gradients and no optimiser state.

Freezing the whole body is very cheap, but it's close to feature extraction (Lesson 8.3), and the body can't adapt at all. PEFT methods keep the body frozen but add a small number of trainable parameters inside it, so the representation itself can adapt.

Key terms

  • requires_grad: the flag that decides whether PyTorch computes a gradient for a tensor; False freezes it.
  • Trainable parameters: those with requires_grad = True; only these get gradients and optimiser states.
Checkpoint Before you move on from §3

What does requires_grad false do?

4

The Core Idea Behind PEFT

Why should a small number of extra parameters be enough to adapt a model? Because the change that fine-tuning makes seems to be much simpler than the weights themselves.

We can check this with a model we already have. The SST-2 sentiment model is DistilBERT base after full fine-tuning (Lesson 7.1). For one weight matrix, the attention query projection, we take the update ΔW = W(SST-2) − W(base) and ask how many "directions" it really uses. The singular value decomposition splits any matrix into a sum of simple rank-1 pieces, each a direction with a strength, sorted from strongest to weakest. If a few pieces carry most of the update, the update is approximately low-rank.

Python 17 lines
sst2 = AutoModelForSequenceClassification.from_pretrained("distilbert/distilbert-base-uncased-finetuned-sst-2-english", revision=MODEL_REVISIONS.get("distilbert/distilbert-base-uncased-finetuned-sst-2-english"))base_body = start.distilbertfig, ax = plt.subplots(figsize=(6.5, 3.6))for layer in (0, 5):    W0 = base_body.transformer.layer[layer].attention.q_lin.weight.detach()    W1 = sst2.distilbert.transformer.layer[layer].attention.q_lin.weight.detach()    s = torch.linalg.svdvals(W1 - W0)                                 # singular values of the fine-tuning update    energy = (s ** 2).cumsum(0) / (s ** 2).sum()    print(f"layer {layer}: the top 8 of {len(s)} directions hold {energy[7]:.1%} of the update | top 64 hold {energy[63]:.1%}")    ax.plot(range(1, len(s) + 1), energy.numpy(), label=f"fine-tuning update, layer {layer}")R = torch.randn(768, 768)er = (torch.linalg.svdvals(R) ** 2).cumsum(0) / (torch.linalg.svdvals(R) ** 2).sum()print(f"random 768 x 768 matrix: the top 8 directions hold {er[7]:.1%}")ax.plot(range(1, 769), er.numpy(), "k:", label="random matrix")ax.set_xscale("log"); ax.set_xlabel("number of directions (log scale)"); ax.set_ylabel("share of the update captured")ax.set_title("How low-rank is a fine-tuning update?"); ax.legend(fontsize=8); ax.grid(alpha=0.3); plt.show()del sst2
Warnings / log
Loading weights:   0%|          | 0/104 [00:00<?, ?it/s]Loading weights: 100%|██████████| 104/104 [00:00<00:00, 18198.06it/s]
Output
layer 0: the top 8 of 768 directions hold 25.1% of the update | top 64 hold 60.6%
layer 5: the top 8 of 768 directions hold 55.8% of the update | top 64 hold 85.1%
random 768 x 768 matrix: the top 8 directions hold 4.0%
Plot output
Plot produced by the code above

The fine-tuning updates are strongly concentrated. In the last layer, just 8 of the 768 directions carry 55.8% of the update, and 64 directions carry 85.1%. In the first layer the update is more spread out (25.1% in the top 8), but still far more concentrated than a random matrix of the same size, whose top 8 directions hold only 4.0%. The plot shows the whole curve: the fine-tuning updates rise steeply on the left, while the random matrix needs hundreds of directions.

That's the core idea behind LoRA. If ΔW is approximately low-rank, we can learn it directly in low-rank form, with far fewer numbers than a full matrix, and leave W untouched.

Key terms

  • Rank: the number of independent directions a matrix uses; a 768 × 768 matrix can have rank up to 768.
  • Low-rank: representable (exactly or approximately) with few directions.
  • Singular value decomposition (SVD): splitting a matrix into rank-1 pieces ordered by strength; used here to measure how low-rank an update is.
Check your understandingCheck yourself
  1. The last layer's update is more concentrated (55.8% in 8 directions) than the first layer's (25.1%). Suggest one reason why fine-tuning might change the last layer in a simpler, more focused way.
Checkpoint Before you move on from §4

Why can a compact update be useful?

5

LoRA: The Intuition

A rank-r matrix of size 768 × 768 can always be written as the product of a tall, thin matrix B (768 × r) and a short, wide matrix A (r × 768). For r = 8 that's 2 × 8 × 768 = 12,288 numbers instead of 589,824. LoRA uses exactly this: next to a frozen weight matrix W, it adds a trainable update in factorised form, so the layer computes

output = W·x + (α / r) · B·(A·x)

where x is the input vector, r is the rank, and α (alpha) is a fixed scaling number.

Two design details make this work well:

  • B starts at zero. So at the start of training B·A = 0, and the model behaves exactly like the pretrained model. Training only ever adds a learned correction.
  • A starts small and random, so the gradients for B are non-zero from the first step and learning can begin.

An analogy: W is a large printed map that we're not allowed to redraw. LoRA is a transparent overlay on which we may draw only a few straight strokes (the rank). For many trips, a few strokes are enough to mark the new route.

Checkpoint Before you move on from §5

How does LoRA modify a linear computation?

6

LoRA Matrices and Low-Rank Updates: Rank, Alpha, Target Layers

6.1 A LoRA layer by hand

Here is a complete LoRA layer in a few lines, wrapping an ordinary nn.Linear:

Python 31 lines
class LoRALinear(nn.Module):    """Add a scaled trainable low-rank correction beside a frozen linear layer."""    def __init__(self, base: nn.Linear, r=8, alpha=16):        """Initialise the components and settings used by this model or service.        Arguments:            base: Frozen base linear layer.            r: Inner rank of the trainable low-rank update.            alpha: Scale numerator for the LoRA update.        """        super().__init__()        self.base = base        for p in self.base.parameters():            p.requires_grad = False                                   # the pretrained weight stays frozen        self.A = nn.Parameter(torch.randn(r, base.in_features) * 0.01)   # (r, d_in): small random        self.B = nn.Parameter(torch.zeros(base.out_features, r))          # (d_out, r): zero, so the update starts at 0        self.scale = alpha / r    def forward(self, x):                                             # x: (..., d_in)        """Compute this module's output from the input tensor without performing an optimiser update.        Arguments:            x: Input features or token IDs with the shape traced at the call site.        """        return self.base(x) + self.scale * (x @ self.A.T) @ self.B.T  # (..., d_out)q = nn.Linear(768, 768)lora_q = LoRALinear(q, r=8, alpha=16)x = torch.randn(2, 5, 768)                                            # 2 sequences of 5 token vectorsprint("output unchanged at the start:", torch.equal(q(x), lora_q(x)))print(f"trainable in this layer: {sum(p.numel() for p in lora_q.parameters() if p.requires_grad):,} "      f"instead of {q.weight.numel():,} ({8 * (768 + 768) / q.weight.numel():.1%})")
Output
output unchanged at the start: True
trainable in this layer: 12,288 instead of 589,824 (2.1%)

At the start, the LoRA layer's output is exactly the same as the original layer's, because B is zero. The trainable part is 12,288 numbers instead of the matrix's 589,824, 2.1% of it.

6.2 Rank

The rank r sets the size of the update, and the trade-off between capacity and cost:

Python 3 lines
for r in (1, 4, 8, 16, 64, 384):    per_matrix = r * (768 + 768)    print(f"rank {r:>3}: {per_matrix:>7,} trainable numbers per 768 x 768 matrix ({per_matrix / 768 ** 2:6.1%} of it)")
Output
rank   1:   1,536 trainable numbers per 768 x 768 matrix (  0.3% of it)
rank   4:   6,144 trainable numbers per 768 x 768 matrix (  1.0% of it)
rank   8:  12,288 trainable numbers per 768 x 768 matrix (  2.1% of it)
rank  16:  24,576 trainable numbers per 768 x 768 matrix (  4.2% of it)
rank  64:  98,304 trainable numbers per 768 x 768 matrix ( 16.7% of it)
rank 384: 589,824 trainable numbers per 768 x 768 matrix (100.0% of it)

Rank 1 needs only 1,536 numbers (0.3% of the matrix); rank 8, 12,288 (2.1%); rank 64, 98,304 (16.7%). At rank 384, half the matrix's width, LoRA already has as many numbers as the full matrix, and there's nothing left to save. In practice, ranks from 4 to 64 are common, and Section 4's measurements suggest why small ranks often suffice.

Alpha scales the update by α / r. Its purpose is practical: when you try a different rank, you can keep α fixed, which changes the explicit scaling factor α/r. The realised update also depends on initialization and optimization, so learning-rate tuning may still be needed. We use r = 8 and α = 16, so the scale is 2.

6.3 Target layers

Which matrices get a LoRA update? A Transformer layer (Lesson 6.4) has the attention projections (query, key, value and output; q_lin, k_lin, v_lin and out_lin in DistilBERT) and the feed-forward network (lin1, lin2). The PEFT library takes a list of names, target_modules, and counts follow directly:

Python 7 lines
for targets in (["q_lin", "v_lin"], ["q_lin", "k_lin", "v_lin", "out_lin"], ["q_lin", "k_lin", "v_lin", "out_lin", "lin1", "lin2"]):    pm = get_peft_model(copy.deepcopy(start),                        LoraConfig(r=8, lora_alpha=16, target_modules=targets, task_type="SEQ_CLS"))    lora_n = sum(p.numel() for n, p in pm.named_parameters() if "lora_" in n)    total = sum(p.numel() for p in pm.parameters() if p.requires_grad)    print(f"LoRA on {', '.join(targets):<38} LoRA weights {lora_n:>9,} | all trainable (incl. head) {total:>9,}")del pm
Output
LoRA on q_lin, v_lin                           LoRA weights   147,456 | all trainable (incl. head)   739,586
LoRA on q_lin, k_lin, v_lin, out_lin           LoRA weights   294,912 | all trainable (incl. head)   887,042
LoRA on q_lin, k_lin, v_lin, out_lin, lin1, lin2 LoRA weights   663,552 | all trainable (incl. head) 1,255,682

LoRA on only the query and value projections adds 147,456 trainable numbers across DistilBERT's 6 layers; on all four attention projections, 294,912; on everything including the feed-forward layers, 663,552. In each case the new classification head (592,130 parameters) is also trained, because it starts random and has to be learned. The original LoRA work found query and value enough for many tasks, and that is our choice. Targeting more layers gives the update more capacity, at a modest cost.

Key terms

  • LoRA rank (r): the inner dimension of the factorised update B·A; it sets how many directions the update can use.
  • LoRA alpha (α): a scaling constant; the update is multiplied by α / r.
  • Target modules: the weight matrices that receive LoRA updates.

Common mistake: forgetting that the classification head must be trained too. LoRA adapts the frozen body, but a new task's head starts random. The PEFT library trains it automatically when task_type="SEQ_CLS" is set; in a hand-built setup you must make it trainable yourself, to implement the intended trainable-head setup.

Visual walkthrough: See why the initial adapter update is zero.

See why the initial adapter update is zero; exact steps are described below.

1. Freeze a 2 × 2 base. W₀ = [[1, 0], [0, 1]] · x = [2, 1]. Use rank r = 1 and α = 1, so α/r = 1. This tiny matrix is an illustration, not a measured trained adapter.

2. Initialize the adapter. A = [[0.2, −0.1]] · B = [[0], [0]]. A starts random; B starts zero. Ax = 0.3, but BAx = [0, 0]. The initial combined adapter update is zero.

3. After an illustrative update. B = [[0.5], [−0.2]] · A held as shown. For illustration, B changes to these supplied values. We are not deriving an optimizer step or running training here.

4. Add the low-rank path. W₀x + BAx = [2, 1] + [0.15, −0.06] = [2.15, 0.94]. The base remains frozen while adapters train. A task head may also train; include it when counting trainable parameters.

The app adds a paused animation of this same supplied example.

Interactive

See why the initial adapter update is zero

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 §6

What are the usual initial LoRA factors?

7

Adapters, Prefix Tuning and Other Approaches (Extension)

LoRA is one member of a family. Two other well-known PEFT methods, for comparison:

  • Bottleneck adapters insert small two-layer networks inside each Transformer layer: squeeze the 768-number vector down to a small size m, apply a non-linearity, and expand back, with a residual connection around them. The pretrained weights stay frozen.
  • Prefix tuning (and its simpler relative, prompt tuning) leaves every weight untouched and instead learns a few extra vectors that are prepended to the keys and values in each attention layer: "virtual tokens" the real tokens can attend to.

We can compare their sizes for DistilBERT by counting:

Python 5 lines
d, layers, m_ = 768, 6, 64                                            # model width, layers, adapter bottleneck sizeadapter = layers * 2 * (2 * d * m_ + m_ + d)                          # two bottleneck adapters per layerprefix = layers * 2 * 20 * d                                          # 20 learned key and value vectors per layerlora = layers * 2 * 8 * (d + d)                                       # LoRA r=8 on two 768 x 768 matrices per layerprint(f"bottleneck adapters (size {m_}): {adapter:,} | prefix tuning (20 vectors): {prefix:,} | LoRA r=8 on q, v: {lora:,}")
Output
bottleneck adapters (size 64): 1,189,632 | prefix tuning (20 vectors): 184,320 | LoRA r=8 on q, v: 147,456

With a bottleneck size of 64, two adapters per layer add 1,189,632 parameters; prefix tuning with 20 vectors adds 184,320; LoRA with rank 8 on query and value adds 147,456. All three are around 1–2% of the model or less. They differ in other ways:

  • Adapters add extra layers that run at inference time, which adds a little latency.
  • Prefix tuning uses up some of the context length.
  • LoRA's update can be merged into W after training (Section 9), so the final model has no extra cost at all.

That last property is a large part of why LoRA became the default choice.

Key terms

  • Bottleneck adapter: a small down-projection, non-linearity and up-projection inserted into each layer, trained while the rest is frozen.
  • Prefix tuning / prompt tuning: learning extra key/value or input vectors while all weights stay frozen.
Checkpoint Before you move on from §7

How do other PEFT methods differ?

8

Practice: LoRA with Hugging Face PEFT, Inspecting Trainable Parameters

8.1 Setting up

The PEFT library wraps a model in a few lines: describe LoRA in a LoraConfig, then call get_peft_model. We use rank 8, alpha 16, LoRA dropout 0.1 (dropout applied to the LoRA input during training), and the query and value projections. task_type="SEQ_CLS" tells the library this is sequence classification, so it also makes the new head trainable.

The training recipe is Lesson 8.4's, unchanged except for the learning rate: 1e-3 instead of 5e-5. LoRA trains small new matrices whose combined update starts at zero (A is random; B is zero), not pretrained weights that must be preserved, so it needs larger steps. Rates around 1e-4 to 1e-3 are typical. We use a common default here rather than tuning it, which would mean another training run.

Python 8 lines
EPOCHS, BATCH, LR, WARMUP = 1, 16, 1e-3, 0.1config = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.1, target_modules=["q_lin", "v_lin"], task_type="SEQ_CLS")torch.manual_seed(0)model = get_peft_model(copy.deepcopy(start), config).to(device)model.print_trainable_parameters()lora_n = sum(p.numel() for n, p in model.named_parameters() if p.requires_grad and "lora_" in n)head_n = sum(p.numel() for n, p in model.named_parameters() if p.requires_grad and "lora_" not in n)print(f"LoRA matrices: {lora_n:,} | new classification head: {head_n:,}")
Output
trainable params: 739,586 || all params: 67,694,596 || trainable%: 1.0925
LoRA matrices: 147,456 | new classification head: 592,130

print_trainable_parameters reports 739,586 trainable out of 67,694,596, 1.0925%. Of these, 147,456 are the LoRA matrices and 592,130 are the new head. The total count is a little larger than DistilBERT's 66,955,010 because the wrapped model also contains the LoRA matrices and a spare copy of the original head, kept so the adapter can be removed again.

8.2 Training

The loop is identical to Lesson 8.4's, except that the optimiser only receives the trainable parameters, and clipping applies only to them. Checkpoints are checked every 16 steps on 200 validation reviews, and the best one is kept.

Python 25 lines
steps = EPOCHS * math.ceil(len(train_ids) / BATCH)opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=LR)   # only the trainable weightssched = get_linear_schedule_with_warmup(opt, num_warmup_steps=math.ceil(WARMUP * steps), num_training_steps=steps)check_ids, check_y = val_ids[:200], val_y[:200]g = torch.Generator().manual_seed(0)log, checkpoints, best = [], [], (-1, None, None)t0 = benchmark_clock(); step = 0for epoch in range(EPOCHS):    for b in torch.randperm(len(train_ids), generator=g).split(BATCH):        model.train()        loss = model(**make_batch([train_ids[i] for i in b], train_y[b.numpy()])).loss        loss.backward()        torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], 1.0)        opt.step(); sched.step(); opt.zero_grad()        step += 1; log.append((step, loss.item()))        if step % 16 == 0 or step == steps:            acc = ((predict(model, check_ids) > 0.5) == check_y).mean()            checkpoints.append((step, acc))            print(f"step {step:>2}: training loss (last 16 steps) {np.mean([l for _, l in log[-16:]]):.3f} | validation accuracy {acc:.3f}")            if acc > best[0]:                best = (acc, step, {k: v.detach().clone() for k, v in model.state_dict().items()})lora_secs = benchmark_clock() - t0model.load_state_dict(best[2])val_p = predict(model, val_ids)print(f"training took {lora_secs:.0f} s | chosen checkpoint: step {best[1]} | validation accuracy (1,000) {((val_p > 0.5) == val_y).mean():.3f}")
Output
step 16: training loss (last 16 steps) 0.633 | validation accuracy 0.790
step 32: training loss (last 16 steps) 0.422 | validation accuracy 0.855
step 48: training loss (last 16 steps) 0.401 | validation accuracy 0.870
step 63: training loss (last 16 steps) 0.421 | validation accuracy 0.860
training took 45 s | chosen checkpoint: step 48 | validation accuracy (1,000) 0.880

Validation accuracy on 200 reviews climbs from 0.790 at step 16 to 0.855 at step 32 and 0.870 at step 48, the best checkpoint, then dips to 0.860. LoRA learns faster at the start than full fine-tuning did in Lesson 8.4: its first checkpoint is already far above chance. That's partly because of its larger learning rate. The chosen checkpoint scores 0.880 on all 1,000 validation reviews. Section 9.4 compares the cost of training with Lesson 8.4's.

Check your understandingCheck yourself
  1. LoRA trains about 1% of the parameters, yet training wasn't 100 times faster than full fine-tuning. Why not?
Checkpoint Before you move on from §8

Why count the classification head too?

9

Merging LoRA Weights for Inference

9.1 Saving just the adapter

Because the pretrained weights never changed, we only need to save what was trained:

Python 7 lines
model.save_pretrained("data/lora-adapter")                            # saves only what was trainedadapter_mb = sum(os.path.getsize(os.path.join("data/lora-adapter", f)) for f in os.listdir("data/lora-adapter")) / 1e6print(f"adapter folder: {sorted(os.listdir('data/lora-adapter'))} | {adapter_mb:.1f} MB")reloaded = PeftModel.from_pretrained(copy.deepcopy(start), "data/lora-adapter").to(device)   # base model + adapterprobe_ids = val_ids[:64]p_peft = predict(model, probe_ids)print("base + reloaded adapter gives identical probabilities:", np.allclose(predict(reloaded, probe_ids), p_peft, atol=1e-6))
Output
adapter folder: ['README.md', 'adapter_config.json', 'adapter_model.safetensors'] | 3.0 MB
base + reloaded adapter gives identical probabilities: True

The adapter folder holds a small configuration file, a README and the trained weights: 3.0 MB in total, against the 267.8 MB of weights a full fine-tuned copy needs (Section 2). Loading the base model and attaching the saved adapter (PeftModel.from_pretrained) gives identical probabilities. A hundred tasks would need one base model plus a hundred small adapters, rather than a hundred full models.

9.2 Merging

For deployment we can merge the update into the frozen weights: replace each W with W + (α / r)·B·A. The model then becomes a plain DistilBERT classifier, with no LoRA layers and no extra computation at inference.

Python 4 lines
merged = copy.deepcopy(model).merge_and_unload()                     # fold B @ A into W: a plain model againprint(type(merged).__name__, "| LoRA modules left:", sum("lora" in n for n, _ in merged.named_modules()))p_merged = predict(merged, probe_ids)print(f"merged vs. unmerged: largest probability difference {np.abs(p_merged - p_peft).max():.2e}")
Output
DistilBertForSequenceClassification | LoRA modules left: 0
merged vs. unmerged: largest probability difference 7.75e-07

After merge_and_unload, the model is an ordinary DistilBertForSequenceClassification with no LoRA modules left, and its probabilities differ from the unmerged model by at most 7.75e-07, only floating-point rounding. So you can choose per deployment: keep the adapter separate when one base model serves many tasks, or merge it when a single task needs maximum speed and simplicity.

9.3 One look at the test set

All choices are made, so we test once, and compare on the same 1,000 test reviews with the results Lessons 8.3 and 8.4 saved:

Python 21 lines
ensure_comparison_results(["8.3", "8.4"])test_p = predict(model, test_ids)                                     # the test set, used oncetest_pred = (test_p > 0.5).astype(int)tp, fp, fn = ((test_pred == 1) & (test_y == 1)).sum(), ((test_pred == 1) & (test_y == 0)).sum(), ((test_pred == 0) & (test_y == 1)).sum()test_metrics = {"accuracy": round(float((test_pred == test_y).mean()), 4),                "recall_neg": round(float(((test_pred == 0) & (test_y == 0)).sum() / (test_y == 0).sum()), 4),                "recall_pos": round(float(tp / (test_y == 1).sum()), 4),                "f1_pos": round(float(2 * tp / (2 * tp + fp + fn)), 4)}print("test (1,000 reviews):", test_metrics)for f in ("results/8.3.json", "results/8.4.json"):                    # written by Lessons 8.3 and 8.4    if not os.path.exists(f):        print(f"{f} not found: run that note first to compare"); continue    rec = json.load(open(f))    assert rec["split_fingerprints"]["test_eval"] == fingerprint(test_x), f"different test reviews in {f}"    others = rec["rungs"] if "rungs" in rec else {"full fine-tuning (8.4)": rec}    for name, r in others.items():        if name == "majority class":            continue        o = np.array(r["test_pred"])        a = int(((test_pred == test_y) & (o != test_y)).sum()); b = int(((test_pred != test_y) & (o == test_y)).sum())        print(f"vs {name:<40} test {r['test']['accuracy']:.3f} | only LoRA right {a:>3} | only other right {b:>3} | p = {(binomtest(a, a + b).pvalue if a + b else 1.0):.2g}")
Output
test (1,000 reviews): {'accuracy': 0.834, 'recall_neg': 0.8289, 'recall_pos': 0.8388, 'f1_pos': 0.8388}
vs TF-IDF, 1,000 labels                     test 0.828 | only LoRA right 104 | only other right  98 | p = 0.73
vs TF-IDF, all labels                       test 0.913 | only LoRA right  43 | only other right 122 | p = 5.9e-10
vs SST-2 model, off the shelf               test 0.873 | only LoRA right  43 | only other right  82 | p = 0.00062
vs frozen DistilBERT + logistic regression  test 0.837 | only LoRA right  57 | only other right  60 | p = 0.85
vs full fine-tuning (8.4)                   test 0.834 | only LoRA right  54 | only other right  54 | p = 1

LoRA scores 0.834 on test, with recall 0.8289 on negatives and 0.8388 on positives. Against full fine-tuning (Lesson 8.4), the result is a dead heat: each is right on 54 reviews the other gets wrong (p = 1). Against the baselines, the picture is the same as for full fine-tuning:

  • no significant difference from TF-IDF or frozen features at 1,000 labels (p = 0.73 and 0.85);
  • significantly below the off-the-shelf SST-2 model (43 against 82, p = 0.00062) and TF-IDF with all labels.

This is a single training run (as was Lesson 8.4's), and a different random initialisation of the LoRA matrices would give slightly different numbers. The safe conclusion is not "LoRA equals full fine-tuning to the review", but that on these 1,000 test reviews there's no evidence of a difference, while LoRA trained about 1% of the parameters and saved a 3.0 MB adapter.

9.4 Forgetting

Finally, what did each approach cost the pretrained model's other skills?

Interpret the evidence carefully. Equal observed accuracy does not mean the classifiers make identical predictions. In the source reference results, full fine-tuning and LoRA disagree on 108 of 1,000 reviews. A paired p-value of 1 does not establish equivalence; stronger equivalence claims need a declared tolerance and an appropriate analysis.

Python 24 lines
mlm = AutoModelForMaskedLM.from_pretrained(BASE, revision=MODEL_REVISIONS.get(BASE)).to(device).eval()     # Lesson 8.1's forgetting measureheld = imdb["val"][0][1000:1200]enc = tok(held, truncation=True, max_length=128, padding=True, return_tensors="pt")gm = torch.Generator().manual_seed(0)special = (enc["input_ids"] == tok.cls_token_id) | (enc["input_ids"] == tok.sep_token_id) | (enc["attention_mask"] == 0)hide = (torch.rand(enc["input_ids"].shape, generator=gm) < 0.15) & ~specialmasked_ids = enc["input_ids"].clone(); masked_ids[hide] = tok.mask_token_idtargets = enc["input_ids"].clone(); targets[~hide] = -100pretrained_body = copy.deepcopy(mlm.distilbert.state_dict())def word_guessing_loss(body_state):    """Measure masked-word loss after loading the body; the output projection remains tied to input embeddings.    Arguments:        body_state: Body state dictionary loaded for the tied-output retention probe.    """    mlm.distilbert.load_state_dict(body_state)    with torch.inference_mode():        return mlm(input_ids=masked_ids.to(device), attention_mask=enc["attention_mask"].to(device), labels=targets.to(device)).loss.item()untouched = all(torch.equal(p.cpu(), pretrained_body[n.split("distilbert.", 1)[1].replace("base_layer.", "")])                for n, p in model.named_parameters() if n.split(".")[2] == "distilbert" and "lora_" not in n)print("with LoRA switched off, every pretrained weight is exactly as it was:", untouched)forget = {"pretrained": round(word_guessing_loss(pretrained_body), 3),          "LoRA merged in": round(word_guessing_loss(merged.distilbert.state_dict()), 3)}print("word-guessing loss on 200 held-out reviews:", forget)
Warnings / log
Loading weights:   0%|          | 0/105 [00:00<?, ?it/s]Loading weights: 100%|██████████| 105/105 [00:00<00:00, 12143.99it/s]
Output
with LoRA switched off, every pretrained weight is exactly as it was: True
word-guessing loss on 200 held-out reviews: {'pretrained': 3.381, 'LoRA merged in': 3.461}

With LoRA switched off, every pretrained weight is exactly as it was. Forgetting is literally zero, by construction: the original model is always one switch away. Even merged into the weights, LoRA raised the word-guessing loss only from 3.381 to 3.461.

To compare costs, we read Lesson 8.4's saved results next to ours. The training-memory estimate uses Section 2's accounting: weights, plus gradients and two Adam states for the trainable parameters only.

Interpret the evidence carefully. This remains the tied-output retention probe from 8.1. Disabling the adapter restores the frozen base path for this comparison; it does not by itself prove retention of every ability a deployed application might need.

Python 12 lines
trainable_n = sum(p.numel() for p in model.parameters() if p.requires_grad)lora_memory = (sum(p.numel() for p in model.parameters()) * 4 + trainable_n * 4 * 3) / 1e6   # weights + grads + 2 Adam statescosts = {"LoRA (this note)": {"trainable": trainable_n, "memory": f"{lora_memory:.0f} MB", "time": f"{lora_secs:.0f} s",                              "val": round(float(((val_p > 0.5) == val_y).mean()), 3), "forgetting": forget["LoRA merged in"]}}if os.path.exists("results/8.4.json"):    r = json.load(open("results/8.4.json"))    costs["full fine-tuning (8.4)"] = {"trainable": r["trainable_parameters"], "memory": f"{r['training_memory_estimate_mb']} MB",                                       "time": r["train_time"], "val": r["val"]["accuracy"],                                       "forgetting": r["forgetting_word_guessing_loss"]["fine-tuned"]}for name, c in costs.items():    print(f"{name:<24} trainable {c['trainable']:>10,} | training memory (estimate) {c['memory']:>8} | "          f"train time {c['time']:>5} | validation {c['val']:.3f} | word-guessing loss {c['forgetting']}")
Output
LoRA (this note)         trainable    739,586 | training memory (estimate)   280 MB | train time  45 s | validation 0.880 | word-guessing loss 3.461
full fine-tuning (8.4)   trainable 66,955,010 | training memory (estimate)  1071 MB | train time  58 s | validation 0.860 | word-guessing loss 4.07

LoRA trained 739,586 parameters against 66,955,010, so its estimated training memory is a fraction of full fine-tuning's, mostly the frozen weights themselves. Training was also quicker, though not a hundred times quicker (Check yourself 3). Validation accuracy was similar: 0.880 for LoRA against 0.860 for full fine-tuning. And full fine-tuning's word-guessing loss was 4.07, against LoRA's 3.461: LoRA's small, low-rank update disturbs the pretrained skill much less.

Everything is saved for Lesson 8.7:

Python 25 lines
os.makedirs("results", exist_ok=True)record = {    "note": "8.5", "method": "LoRA (r=8, alpha=16, q_lin + v_lin; head trained)",    "split_fingerprints": {k: fingerprint(imdb[k][0]) for k in ("train", "val_eval", "test_eval")},    "train_subset_fingerprint": fingerprint(train_x),    "settings": {"model": BASE, "max_length": 128, "truncation": "head+tail", "epochs": EPOCHS, "batch_size": BATCH,                 "learning_rate": LR, "warmup": WARMUP, "grad_clip": 1.0, "rank": 8, "alpha": 16, "chosen_step": int(best[1])},    "labels_used": 1000, "trainable_parameters": trainable_n,    "train_time": f"{lora_secs:.0f} s",    "training_memory_estimate_mb": round(lora_memory),    "saved_size_mb": round(adapter_mb, 1),    "val": {"accuracy": round(float(((val_p > 0.5) == val_y).mean()), 4)},    "test": test_metrics, "test_pred": test_pred.tolist(),    "forgetting_word_guessing_loss": forget,    "train_loss_curve": [(s, round(l, 4)) for s, l in log],    "val_checkpoints": [(s, round(float(a), 4)) for s, a in checkpoints],}record['ordered_test_fingerprint'] = ordered_fingerprint(test_x, test_y)record['artifact_origin'] = 'learner execution of this notebook'record['model_revisions'] = MODEL_REVISIONSrecord['dataset_revisions'] = DATASET_REVISIONSwith open("results/8.5.json", "w") as f:    json.dump(record, f, indent=1)shutil.rmtree("data/lora-adapter")print("wrote results/8.5.json")
Output
wrote results/8.5.json

Common mistake: concluding from one run that LoRA is "better" or "worse" than full fine-tuning by a fraction of a point. Single runs on 1,000 reviews differ by several reviews from initialisation and shuffling alone. Compare on the same test examples with a paired test, and treat small differences as noise unless the test says otherwise.

Checkpoint Before you move on from §9

Does p equal one prove LoRA and full fine-tuning equivalent?

Putting It All Together

Let's reassemble the PEFT story.

Full fine-tuning is expensive because every trainable weight needs a gradient and two Adam states: DistilBERT's 267.8 MB of weights became 1071.3 MB in training, and a 7B model would need about 121.8 GB. Freezing parameters removes their gradients and optimiser states. Freezing the whole body leaves only 592,130 trainable parameters, but then the body can't adapt.

The core idea of PEFT is that fine-tuning's changes are simpler than the weights. The real update from DistilBERT to its SST-2 version concentrated 55.8% of its strength in 8 of 768 directions in the last layer, against 4.0% for a random matrix. LoRA learns such an update directly in low-rank form: next to each frozen W, two thin matrices B and A, with B starting at zero, so training begins from the pretrained model exactly. Rank 8 on a 768 × 768 matrix trains 12,288 numbers instead of 589,824; alpha scales the update; target modules choose which matrices get one. Adapters and prefix tuning are alternatives of similar size, but only LoRA can be merged away.

In practice, LoRA on the query and value projections trained 739,586 parameters (1.0925%, including the new head). It reached 0.880 on validation and 0.834 on test, equal in observed accuracy to full fine-tuning in this run (p = 1; not proof of equivalence), with a 3.0 MB adapter instead of 267.8 MB of weights. Merging gave a plain model with outputs equal to within 7.75e-07. And it forgot far less: zero when switched off, and a word-guessing loss of 3.461 when merged, against 4.07 for full fine-tuning. Neither approach beat the off-the-shelf SST-2 model with 1,000 labels. That's the question Lesson 8.7 takes up.

Full fine-tuning (8.4) LoRA (this lesson)
Trainable parameters 66,955,010 739,586 (1.0925%)
Saved for the task full model 3.0 MB adapter
Validation accuracy (1,000) 0.860 0.880
Test accuracy (1,000) 0.834 0.834 (p = 1)
Word-guessing loss (pretrained 3.381) 4.07 3.461 merged; unchanged when switched off
Same Accuracy, a Fraction of the Cost. Both source runs achieved 0.834 test accuracy but disagreed on 108 of 1,000 predictions. Equal observed accuracy and p=1 do not prove equivalence. Memory estimates exclude activations.

Figure 8.5.2. Both source runs achieved 0.834 test accuracy but disagreed on 108 of 1,000 predictions. Equal observed accuracy and p=1 do not prove equivalence. Memory estimates exclude activations.

Summary

  • Full fine-tuning with AdamW needs about 16 bytes per trainable parameter in float32: DistilBERT's 267.8 MB of weights became 1071.3 MB with gradients and Adam states; a 7B model would need about 121.8 GB.
  • Freezing (requires_grad = False) removes gradients and optimiser states for those parameters.
  • Fine-tuning updates are approximately low-rank: in the SST-2 model's last layer, 8 of 768 directions held 55.8% of the update, against 4.0% for a random matrix.
  • LoRA adds a trainable update (α / r)·B·A beside each frozen target matrix; B starts at zero so training begins from the pretrained model exactly. Rank 8 on a 768 × 768 matrix trains 12,288 numbers instead of 589,824.
  • Rank sets capacity, alpha scales the update, target modules choose the matrices; the new classification head must be trained too.
  • Adapters and prefix tuning are similar-sized alternatives; LoRA's update can be merged away, adding nothing at inference.
  • With PEFT, LoRA on q_lin and v_lin trained 739,586 parameters (1.0925%), reached 0.880 on validation and 0.834 on test, equal in observed accuracy to full fine-tuning in this run (p = 1), and saved a 3.0 MB adapter.
  • Merging gave a plain model with outputs equal to within 7.75e-07. LoRA forgot far less: zero when switched off; 3.461 word-guessing loss merged, against 4.07 for full fine-tuning.

Glossary

Term Meaning
PEFT Parameter-efficient fine-tuning: training a small number of new or selected parameters while the rest stay frozen.
Optimiser state Extra per-weight numbers kept by an optimiser; AdamW keeps two.
Training memory Weights + gradients + optimiser states + activations.
Freezing Setting requires_grad = False so a parameter is not trained.
Trainable parameters Parameters that receive gradients and updates.
Rank The number of independent directions a matrix uses.
Low-rank Representable with few directions.
Singular value decomposition Splitting a matrix into ranked rank-1 pieces.
LoRA Low-rank adaptation: learning ΔW as B·A beside a frozen W.
LoRA rank (r) The inner dimension of B·A.
LoRA alpha (α) A scaling constant; the update is multiplied by α / r.
Target modules The weight matrices that receive LoRA updates.
LoRA dropout Dropout applied to the LoRA input during training.
Bottleneck adapter A small down/up projection inserted into each layer.
Prefix tuning Learning extra key/value vectors that tokens can attend to.
Adapter (PEFT) The saved set of trained PEFT weights for one task.
Merging Folding a LoRA update into the frozen weights: W + (α / r)·B·A.

Wrap-up

Knowledge check

Quick check

1

What does parameter-efficient adaptation aim to reduce?

2

How does LoRA modify a linear computation?

3

Does p equal one prove LoRA and full fine-tuning equivalent?

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.