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

Evaluating Adapted Models

Classroom material

37 min · 18 code cells · 0 labs

In this lesson
Time
~37 min
Sections
12
Code cells
18
Figures
4

Before you start

How do we know whether adaptation worked, and how do we decide whether to keep an adapted model?

You will be able to
  • read training curves and explain what training and validation signals tell you;
  • report task metrics with confidence intervals;
  • analyse errors: which reviews each method gets wrong, where methods agree, and patterns in the failures;
  • identify overfitting warnings and interpret the limitations of a saved retention probe;
  • compare methods on quality, trainable parameters, time and memory, with paired significance tests;
  • measure run-to-run variation and track experiments;
  • make and document a keep-or-reject decision by rules set in advance.

We have now adapted DistilBERT twice, fully in Lesson 8.4 and with LoRA in Lesson 8.5, and built a ladder of baselines in Lesson 8.3. Each note printed its own numbers. But a pile of numbers isn't an answer. The question that matters is: did adaptation work well enough to use? Answering it honestly takes more than reading off the highest score. We need to know whether the differences are real or noise, what kinds of mistakes each model makes, whether a model overfitted or forgot things it needed, what each method cost, and whether the results can be reproduced. Then we need a decision, made by rules we can defend, and a record of it.

This lesson does all of that, without training anything. It reads the results files saved by Lessons 8.3–8.5, checks that every method was evaluated on exactly the same reviews, and walks through a complete evaluation, ending in a keep-or-reject decision.

Key question: How do we know whether adaptation worked, and how do we decide whether to keep an adapted model?

Prerequisites

Run Lessons 8.3, 8.4 and 8.5 first to compare your own experiments. You can also study this lesson independently: it restores missing comparison files from clearly labelled author reference data, preserving any result files you already have. It uses everything from Module 8, the sign test (Module 5) and the decision record from Lesson 7.7.

Setup — imports, versions and device detection used by the notebook (1 cells)

This lesson trains no models, so it runs in seconds. The usual harmless warnings may appear, plus one "Token indices sequence length is longer than…" message in Section 4, where we count the full length of each test review on purpose.

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 53 lines
import os, json, hashlib, platformimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport torch, transformers, peftfrom scipy.stats import binomtestfrom datasets import load_datasetfrom sklearn.feature_extraction.text import TfidfVectorizerfrom sklearn.linear_model import LogisticRegressionfrom transformers import AutoTokenizernp.random.seed(0)print("PyTorch", torch.__version__, "| Transformers", transformers.__version__, "| PEFT", peft.__version__)# 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 23:21:19.379000 4811 site-packages/torch/distributed/elastic/multiprocessing/redirects.py:35] NOTE: Redirects are currently not supported in MacOs.
W1017 23:21:19.471000 4811 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:21:19.490000 4811 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 | Transformers 5.20.1 | PEFT 0.21.2
1

The Big Picture: How Do We Know Adaptation Worked?

"Worked" has several meanings, and a good evaluation checks each one:

Question Where it's answered
Did training behave sensibly? training and validation curves (Section 2)
How good is the model, with what uncertainty? task metrics with confidence intervals (Section 3)
What does it get wrong, and why? error analysis (Section 4)
Did it overfit, or forget? validation–test gaps, forgetting measure (Section 5)
Is it better than the alternatives, and at what cost? the full comparison with paired tests (Section 6)
Would we get the same result again? variation and tracking (Section 7)
Should we use it? rules decided in advance (Section 8), then a written record (Section 9)
Seven Questions Before Keeping a Model. Evaluation combines training behaviour, held-out metrics, errors, retention, costs, reproducibility and the application decision. A p-value alone does not settle suitability.

Figure 8.7.1. Evaluation combines training behaviour, held-out metrics, errors, retention, costs, reproducibility and the application decision. A p-value alone does not settle suitability.

Loading the results

The fixed split is rebuilt with the same function as every other note. Then we load the three results files and check that each one used the same split and the same 1,000 training reviews. If not, comparing them would be meaningless, so the cell stops.

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 5 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)}")test_x, test_y = imdb["test_eval"]train_x = imdb["train"][0][:1000]
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
Python 17 lines
ensure_comparison_results(["8.3", "8.4", "8.5"])need = {"8.3": "results/8.3.json", "8.4": "results/8.4.json", "8.5": "results/8.5.json"}missing = [n for n, f in need.items() if not os.path.exists(f)]assert not missing, f"run Note(s) {', '.join(missing)} first: their results files are missing"files = {n: json.load(open(f)) for n, f in need.items()}expected = {k: fingerprint(imdb[k][0]) for k in ("train", "val_eval", "test_eval")}for n, rec in files.items():    ok = rec["split_fingerprints"] == expected and rec["train_subset_fingerprint"] == fingerprint(train_x)    print(f"Note {n}: same split and same 1,000 training reviews: {ok}")    assert ok, f"Note {n} was run on different data: rerun it"# The legacy membership digest does not establish prediction order. New runs# also record the ordered text/label digest; bundled original records are# explicitly identified as source reference artifacts, with their declared order.for note, rec in files.items():    ordered = rec.get('ordered_test_fingerprint')    if ordered is not None:        assert ordered == ordered_fingerprint(test_x, test_y), 'Prediction ordering differs: ' + note
Output
Note 8.3: same split and same 1,000 training reviews: True
Note 8.4: same split and same 1,000 training reviews: True
Note 8.5: same split and same 1,000 training reviews: True

All three notes ran on the same data. Here is everything they measured, side by side:

Python 7 lines
methods = {}for name, r in files["8.3"]["rungs"].items():    methods[name] = rmethods["full fine-tuning"] = files["8.4"]methods["LoRA"] = files["8.5"]for name, r in methods.items():    print(f"{name:<40} labels {r['labels_used']:>6,} | val {r['val']['accuracy']:.3f} | test {r['test']['accuracy']:.3f}")
Output
majority class                           labels  1,000 | val 0.505 | test 0.485
TF-IDF, 1,000 labels                     labels  1,000 | val 0.866 | test 0.828
TF-IDF, all labels                       labels 22,404 | val 0.921 | test 0.913
SST-2 model, off the shelf               labels      0 | val 0.888 | test 0.873
frozen DistilBERT + logistic regression  labels  1,000 | val 0.850 | test 0.837
full fine-tuning                         labels  1,000 | val 0.860 | test 0.834
LoRA                                     labels  1,000 | val 0.880 | test 0.834

Seven methods, all scored on the same 1,000 validation and 1,000 test reviews. The rest of this lesson works out what these numbers mean.

Checkpoint Before you move on from §1

What must be checked before combining result files?

2

Training vs. Validation Loss

During training, two kinds of signal tell you whether things are going well. The training loss shows whether the model is fitting the data it learns from. A validation signal, validation loss or validation accuracy on data the model never trains on, shows whether that learning carries over to new data. Overfitting appears as the two pulling apart: training keeps improving while validation stalls or worsens (Lesson 8.1).

Lessons 8.4 and 8.5 saved the training loss at every step and the validation accuracy at each checkpoint:

Python 15 lines
fig, axes = plt.subplots(1, 2, figsize=(10, 3.6))for name, colour in (("full fine-tuning", "tab:orange"), ("LoRA", "tab:blue")):    s, l = zip(*methods[name]["train_loss_curve"])    smooth = np.convolve(l, np.ones(8) / 8, mode="valid")    axes[0].plot(s[7:], smooth, color=colour, label=name)    cs, ca = zip(*methods[name]["val_checkpoints"])    axes[1].plot(cs, ca, "o-", color=colour, label=name)axes[0].set_title("training loss (average of 8 steps)"); axes[0].set_xlabel("step")axes[1].set_title("validation accuracy (200 reviews)"); axes[1].set_xlabel("step")for a in axes: a.grid(alpha=0.3); a.legend()plt.tight_layout(); plt.show()for name in ("full fine-tuning", "LoRA"):    l = [v for _, v in methods[name]["train_loss_curve"]]    print(f"{name:<17} training loss: first 8 steps {np.mean(l[:8]):.3f} -> last 8 steps {np.mean(l[-8:]):.3f} | "          f"chosen step {methods[name]['settings']['chosen_step']} | validation (1,000) {methods[name]['val']['accuracy']:.3f}")
Plot output
Plot produced by the code above
Output
full fine-tuning  training loss: first 8 steps 0.692 -> last 8 steps 0.436 | chosen step 48 | validation (1,000) 0.860
LoRA              training loss: first 8 steps 0.688 -> last 8 steps 0.440 | chosen step 48 | validation (1,000) 0.880

Both runs started at a training loss near ln 2 (0.692 and 0.688 over the first 8 steps, the untrained-head value from Lesson 8.4) and ended at 0.436 and 0.440 over the last 8 steps. LoRA's loss fell faster early on, mostly because of its larger learning rate, and its first checkpoint was already far above chance, while full fine-tuning's was still at chance. Both peaked on validation at step 48 and dipped slightly at the end: a mild sign that one epoch was about right, and that checkpoint selection mattered.

A note on what we didn't record: validation loss. Validation accuracy only counts right and wrong; validation loss also shows whether the model is becoming over-confident on the reviews it gets wrong, often an earlier sign of overfitting. Recording it costs nothing extra during evaluation, and an experiment record should include it. That's a lesson for the next run, not something to invent now.

Key terms

  • Training loss: the loss on the batches being trained on; shows whether the model fits its training data.
  • Validation loss / accuracy: the same measures on held-out data; shows whether learning generalises.
Checkpoint Before you move on from §2

Does a lower training loss establish a better deployed model?

3

Task-Specific Metrics

Our task's metrics are accuracy, recall on each class, and F1 for the positive class (Lesson 8.2). Any accuracy measured on 1,000 reviews is uncertain, so we add a 95% confidence interval: a range that, by a standard calculation (the Wilson interval), is likely to contain the accuracy we'd see on unlimited reviews like these.

Python 21 lines
def wilson(k, n, z=1.96):    """Calculate a Wilson confidence interval for a binomial proportion under the stated normal quantile.    Arguments:        k: Number of successful observations.        n: Total number of binomial observations.        z: Normal quantile used for the Wilson interval.    """    p = k / n; d = 1 + z * z / n    c = (p + z * z / (2 * n)) / d; h = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d    return c - h, c + hrows = []for name, r in methods.items():    pred = np.array(r["test_pred"])    k = int((pred == test_y).sum())    lo, hi = wilson(k, len(test_y))    rows.append({"method": name, "accuracy": k / len(test_y), "95% interval": f"{lo:.3f}-{hi:.3f}",                 "recall neg": r["test"]["recall_neg"], "recall pos": r["test"]["recall_pos"], "F1 pos": r["test"]["f1_pos"]})table = pd.DataFrame(rows).set_index("method")print(table.round(3).to_string())
Output
                                         accuracy 95% interval  recall neg  recall pos  F1 pos
method                                                                                        
majority class                              0.485  0.454-0.516       1.000       0.000   0.000
TF-IDF, 1,000 labels                        0.828  0.803-0.850       0.821       0.835   0.833
TF-IDF, all labels                          0.913  0.894-0.929       0.920       0.907   0.915
SST-2 model, off the shelf                  0.873  0.851-0.892       0.856       0.889   0.878
frozen DistilBERT + logistic regression     0.837  0.813-0.859       0.835       0.839   0.841
full fine-tuning                            0.834  0.810-0.856       0.874       0.796   0.832
LoRA                                        0.834  0.810-0.856       0.829       0.839   0.839

The intervals are roughly four to five points wide. Full fine-tuning and LoRA both score 0.834, with intervals of 0.810–0.856. The off-the-shelf SST-2 model scores 0.873 (0.851–0.892), and TF-IDF with all labels 0.913 (0.894–0.929). Overlapping intervals don't settle a comparison on their own; for that we use the paired sign test (Section 6), which is more sensitive because it compares methods review by review.

The recall columns show a difference the accuracies hide. Full fine-tuning catches negative reviews well (0.874) but misses more positive ones (0.796), while LoRA is balanced (0.829 and 0.839). If the feature's purpose were to catch unhappy customers, full fine-tuning's profile might even be preferable, despite equal accuracy. Metrics should match what the application needs (Lesson 7.5).

Key terms

  • Confidence interval: a range likely to contain the true value of a measured quantity; here, accuracy on unlimited data.
  • Wilson interval: a standard way to compute a confidence interval for a proportion.
Check your understandingCheck yourself
  1. Two models' 95% intervals overlap. Does that prove there's no real difference between them? What would you do instead?
Checkpoint Before you move on from §3

What does an accuracy confidence interval describe?

4

Qualitative Evaluation and Error Analysis

4.1 Who gets what wrong

Metrics count errors; error analysis looks at them. First, how much do methods agree on which reviews are hard? The table counts test reviews that both the row's and the column's method got wrong. The diagonal shows each method's own number of errors.

Python 9 lines
names = ["TF-IDF, 1,000 labels", "frozen DistilBERT + logistic regression", "SST-2 model, off the shelf",         "full fine-tuning", "LoRA", "TF-IDF, all labels"]short = {"TF-IDF, 1,000 labels": "TF-IDF 1k", "frozen DistilBERT + logistic regression": "frozen",         "SST-2 model, off the shelf": "SST-2", "full fine-tuning": "full FT", "LoRA": "LoRA", "TF-IDF, all labels": "TF-IDF all"}wrong = {n: np.array(methods[n]["test_pred"]) != test_y for n in names}overlap = pd.DataFrame([[int((wrong[a] & wrong[b]).sum()) for b in names] for a in names],                       index=[short[n] for n in names], columns=[short[n] for n in names])print("test reviews both methods got wrong (diagonal: each method's own errors):")print(overlap.to_string())
Output
test reviews both methods got wrong (diagonal: each method's own errors):
            TF-IDF 1k  frozen  SST-2  full FT  LoRA  TF-IDF all
TF-IDF 1k         172      74     46       67    68          68
frozen             74     163     65       85   106          47
SST-2              46      65    127       77    84          31
full FT            67      85     77      166   112          42
LoRA               68     106     84      112   166          44
TF-IDF all         68      47     31       42    44          87

Full fine-tuning and LoRA each get 166 reviews wrong, and 112 of those are shared. They're the same model architecture trained on the same data, so they fail in similar places. LoRA also shares 106 errors with the frozen encoder: all three use the same pretrained DistilBERT representation, which seems to struggle with the same reviews. The SST-2 model and TF-IDF with all labels share far fewer errors with the others: they fail differently.

4.2 Patterns

Python 8 lines
both_ft = wrong["full fine-tuning"] & wrong["LoRA"]print(f"wrong for both fine-tuned models: {both_ft.sum()} | of those, right for the SST-2 model: {(both_ft & ~wrong['SST-2 model, off the shelf']).sum()}")print(f"wrong for every one of the {len(names)} methods: {np.logical_and.reduce([wrong[n] for n in names]).sum()}")tok = AutoTokenizer.from_pretrained("distilbert/distilbert-base-uncased", revision=MODEL_REVISIONS.get("distilbert/distilbert-base-uncased"))n_tok = np.array([len(ids) for ids in tok(test_x, add_special_tokens=True)["input_ids"]])for label, mask in (("fits in 128 tokens", n_tok <= 128), ("longer than 128 tokens", n_tok > 128)):    accs = " | ".join(f"{short[n]} {1 - wrong[n][mask].mean():.3f}" for n in names)    print(f"{label:<23} ({mask.sum():>3} reviews): {accs}")
Output
wrong for both fine-tuned models: 112 | of those, right for the SST-2 model: 46
wrong for every one of the 6 methods: 12
Warnings / log
[transformers] Token indices sequence length is longer than the specified maximum sequence length for this model (903 > 512). Running this sequence through the model will result in indexing errors
Output
fits in 128 tokens      (121 reviews): TF-IDF 1k 0.818 | frozen 0.909 | SST-2 0.917 | full FT 0.884 | LoRA 0.876 | TF-IDF all 0.909
longer than 128 tokens  (879 reviews): TF-IDF 1k 0.829 | frozen 0.827 | SST-2 0.867 | full FT 0.827 | LoRA 0.828 | TF-IDF all 0.914

Of the 112 reviews both fine-tuned models got wrong, the SST-2 model got 46 right, so those are worth reading. Only 12 reviews defeat all six methods, perhaps hard or even mislabelled ones. Splitting by length shows something else. On the 121 test reviews that fit within 128 tokens, the DistilBERT-based methods all do well (0.876–0.917). On the 879 longer reviews, which head+tail truncation had to cut, every method that reads only 128 tokens drops, while TF-IDF with all labels, which reads every word, holds at 0.914. Part of the gap between the transformer methods and that TF-IDF model is simply that TF-IDF saw the whole review.

Here are the first three reviews that both fine-tuned models got wrong but the SST-2 model got right:

Python 4 lines
idx = np.where(both_ft & ~wrong["SST-2 model, off the shelf"])[0][:3]    # the first three such reviewsfor i in idx:    print(f"\ntrue: {'positive' if test_y[i] else 'negative'} | {n_tok[i]} tokens")    print("  " + test_x[i][:280] + " ...")
Output
true: positive | 367 tokens
  That's a bad, raunchy, predictable, tacky, salacious soap opera? "The Best of Everything" is just such a guilty pleasure for me, something along the lines of "Valley of the Dolls". I mean, "Best" has everything. Somebody gets pregnant out of wedlock (when's the last time you hear ...

true: positive | 175 tokens
  The main reason to check this one out is to watch Laura Gemser in all her glory.  That's reason enough for me.  She heads to Africa as guests of another rich guy that seems to be all over these films. Huge mansion near the jungle. Hunters staying around for parties when they aren ...

true: positive | 281 tokens
  With all the shoot em up, blood horror movies that have come our way in the last little while "Saw, Hostal, Saw 2, The Hills have eyes" Yes, they have their place, don't get me wrong! I went to see "When a stranger calls" with my buddy the other night! Why? Because it's a remake  ...

All three are positive reviews written with plenty of negative-sounding words. One calls the film "a bad, raunchy, predictable, tacky, salacious soap opera" and then a "guilty pleasure". One praises the film for a reason other than its quality. One opens by comparing it with gory horror films. The fine-tuned models, trained on just 1,000 reviews, seem to have learned that negative words mean a negative review. The SST-2 model, trained on many more labelled sentences, handles the reversal. That points to a concrete improvement: more training examples of exactly this kind of review.

Key terms

  • Error analysis: examining which examples a model gets wrong, and why.
  • Error overlap: how many errors two models share; low overlap means they fail differently, which also makes them good candidates to combine.

Common mistake: choosing error examples by hand because they "look interesting". Choose them by a rule (here, the first three that meet a condition), or you may be picking the examples that support a story you already believe.

Checkpoint Before you move on from §4

Why inspect error overlap?

5

Detecting Overfitting and Catastrophic Forgetting

5.1 The validation–test gap

Settings (regularisation, checkpoints) were chosen on validation, so each method's validation score is slightly optimistic, and the test score is the honest one. A large gap between them warns that a method was tuned to its validation data, a form of overfitting to the selection process.

Python 3 lines
for name in ("TF-IDF, 1,000 labels", "frozen DistilBERT + logistic regression", "full fine-tuning", "LoRA"):    r = methods[name]    print(f"{name:<40} validation {r['val']['accuracy']:.3f} | test {r['test']['accuracy']:.3f} | gap {r['val']['accuracy'] - r['test']['accuracy']:+.3f}")
Output
TF-IDF, 1,000 labels                     validation 0.866 | test 0.828 | gap +0.038
frozen DistilBERT + logistic regression  validation 0.850 | test 0.837 | gap +0.013
full fine-tuning                         validation 0.860 | test 0.834 | gap +0.026
LoRA                                     validation 0.880 | test 0.834 | gap +0.046

All four methods that chose settings on validation score lower on test, by between 0.013 and 0.046. LoRA has the largest gap (0.880 → 0.834): its validation score looked best among the 1,000-label methods, but on test it is level with the rest. That is why final test reporting must remain separate from selecting settings on validation, and why a single validation result should never be trusted on its own (Section 7 shows how much results vary).

5.2 Forgetting

Interpret the evidence carefully. A validation–test gap alone cannot diagnose overfitting: sampling variation and selection on validation can also produce one. Examine training and held-out learning curves, and reserve a fresh final test after any further model selection.

Python 3 lines
f4, f5 = methods["full fine-tuning"]["forgetting_word_guessing_loss"], methods["LoRA"]["forgetting_word_guessing_loss"]print(f"word-guessing loss on 200 held-out reviews: pretrained {f4['pretrained']} | full fine-tuning {f4['fine-tuned']} | "      f"LoRA merged {f5['LoRA merged in']} | LoRA switched off: unchanged by construction")
Output
word-guessing loss on 200 held-out reviews: pretrained 3.381 | full fine-tuning 4.07 | LoRA merged 3.461 | LoRA switched off: unchanged by construction

The forgetting measure from Lesson 8.1 tells a clear story: full fine-tuning raised the word-guessing loss from 3.381 to 4.07, while LoRA, even merged, raised it only to 3.461, and with the adapter switched off it is unchanged. For this application we don't need word-guessing, so forgetting doesn't count against either model here. In applications where the adapted model must keep general abilities, it would weigh heavily in LoRA's favour.

Check your understandingCheck yourself
  1. Why is a validation–test gap expected for every method that chose settings on validation, and when should a gap worry you?
Checkpoint Before you move on from §5

Does one retention probe measure every old capability?

6

Comparing Across the Ladder: Performance, Trainable Parameters, Time, Memory

6.1 The whole ladder

Here is every method with what it used and what it cost. Training times were measured in each note on this machine and printed with units; compare them relative to each other, since they vary between machines and runs.

Python 12 lines
cost = {"majority class": (0, "-"), "TF-IDF, 1,000 labels": (methods["TF-IDF, 1,000 labels"]["trained_parameters"], methods["TF-IDF, 1,000 labels"]["train_time"]),        "TF-IDF, all labels": (methods["TF-IDF, all labels"]["trained_parameters"], methods["TF-IDF, all labels"]["train_time"]),        "SST-2 model, off the shelf": (0, "-"),        "frozen DistilBERT + logistic regression": (methods["frozen DistilBERT + logistic regression"]["trained_parameters"], methods["frozen DistilBERT + logistic regression"]["train_time"]),        "full fine-tuning": (methods["full fine-tuning"]["trainable_parameters"], methods["full fine-tuning"]["train_time"]),        "LoRA": (methods["LoRA"]["trainable_parameters"], methods["LoRA"]["train_time"])}mem = {"full fine-tuning": f"{methods['full fine-tuning']['training_memory_estimate_mb']} MB",       "LoRA": f"{methods['LoRA']['training_memory_estimate_mb']} MB"}comp = pd.DataFrame([{"method": n, "labels": methods[n]["labels_used"], "trained params": cost[n][0],                      "train time": cost[n][1], "training memory": mem.get(n, "-"),                      "test acc": methods[n]["test"]["accuracy"]} for n in cost]).set_index("method")print(comp.to_string())
Output
                                         labels  trained params train time training memory  test acc
method                                                                                              
majority class                             1000               0          -               -     0.485
TF-IDF, 1,000 labels                       1000           29755      0.1 s               -     0.828
TF-IDF, all labels                        22404          399451      4.2 s               -     0.913
SST-2 model, off the shelf                    0               0          -               -     0.873
frozen DistilBERT + logistic regression    1000             769      9.9 s               -     0.837
full fine-tuning                           1000        66955010       58 s         1071 MB     0.834
LoRA                                       1000          739586       45 s          280 MB     0.834

The costs span orders of magnitude:

  • TF-IDF trains tens or hundreds of thousands of word weights in seconds.
  • The frozen encoder trains 769 weights after one feature pass.
  • LoRA trains 739,586 parameters, full fine-tuning all 66,955,010. Their estimated training memory is 280 MB and 1071 MB.
  • The SST-2 model trains nothing on IMDb. Note, though, that it isn't free in an absolute sense: someone else fine-tuned it on a large labelled dataset, and we inherit that work.

6.2 Is anything better than the best cheap baseline?

The strongest method using no more than 1,000 IMDb labels is the SST-2 model off the shelf. We compare everything with it on the same test reviews:

Python 6 lines
best_baseline = "SST-2 model, off the shelf"                                 # the strongest method using <= 1,000 labelsref = np.array(methods[best_baseline]["test_pred"]) == test_yfor name in ("full fine-tuning", "LoRA", "frozen DistilBERT + logistic regression", "TF-IDF, 1,000 labels", "TF-IDF, all labels"):    right = np.array(methods[name]["test_pred"]) == test_y    a, b = int((right & ~ref).sum()), int((~right & ref).sum())    print(f"{name:<40} vs {best_baseline}: only it right {a:>3} | only baseline right {b:>3} | p = {(binomtest(a, a + b).pvalue if a + b else 1.0):.2g}")
Output
full fine-tuning                         vs SST-2 model, off the shelf: only it right  50 | only baseline right  89 | p = 0.0012
LoRA                                     vs SST-2 model, off the shelf: only it right  43 | only baseline right  82 | p = 0.00062
frozen DistilBERT + logistic regression  vs SST-2 model, off the shelf: only it right  62 | only baseline right  98 | p = 0.0055
TF-IDF, 1,000 labels                     vs SST-2 model, off the shelf: only it right  81 | only baseline right 126 | p = 0.0022
TF-IDF, all labels                       vs SST-2 model, off the shelf: only it right  96 | only baseline right  56 | p = 0.0015

Every 1,000-label method is significantly worse than the off-the-shelf model:

  • full fine-tuning: 50 against 89 reviews, p = 0.0012;
  • LoRA: 43 against 82, p = 0.00062;
  • the frozen encoder and TF-IDF with 1,000 labels likewise.

Only TF-IDF trained on all 22,404 labels beats it (96 against 56, p = 0.0015). The picture in one chart:

Python 10 lines
fig, ax = plt.subplots(figsize=(7, 3.8))for n in cost:    if n == "majority class":        continue    x = max(cost[n][0], 1)    ax.scatter(x, methods[n]["test"]["accuracy"], s=60)    ax.annotate(n.replace(", off the shelf", "\n(off the shelf)").replace(" + logistic regression", "\n+ head"),                (x, methods[n]["test"]["accuracy"]), fontsize=7, xytext=(5, 4), textcoords="offset points")ax.set_xscale("log"); ax.set_xlabel("parameters trained on IMDb (log scale; 1 = none)"); ax.set_ylabel("test accuracy (1,000 reviews)")ax.set_title("What each method trained, and what it scored"); ax.grid(alpha=0.3); ax.set_ylim(0.8, 0.93); plt.show()
Plot output
Plot produced by the code above

The chart makes the lesson hard to miss. Training more parameters on the same 1,000 labels didn't buy accuracy: the frozen encoder (769 parameters), LoRA (739,586) and full fine-tuning (66,955,010) all land at about the same height. The two winners got their advantage from more labelled data: the SST-2 model indirectly, through someone else's large dataset, and TF-IDF directly, from all 22,404 IMDb labels.

Check your understandingCheck yourself
  1. A colleague concludes "fine-tuning doesn't work for sentiment analysis". What's wrong with that conclusion, given what was and wasn't varied here?
Checkpoint Before you move on from §6

What should a fair comparison disclose?

7

Reproducibility and Experiment Tracking

7.1 How much do results vary?

Every adapted model here comes from a single training run. How much might a result change with a different random draw? We can't afford to retrain the Transformers many times on a CPU, but we can measure the variation for the cheapest rung: TF-IDF trained on five different random sets of 1,000 training reviews, scored on the same validation reviews.

Python 12 lines
val_x, val_y = imdb["val_eval"]all_x, all_y = imdb["train"]rng = np.random.default_rng(0)accs = []for draw in range(5):                                                        # 5 different sets of 1,000 training reviews    idx = rng.choice(len(all_x), 1000, replace=False)    vec = TfidfVectorizer(min_df=2, ngram_range=(1, 2), sublinear_tf=True)    X = vec.fit_transform([all_x[i] for i in idx])    clf = LogisticRegression(C=3, max_iter=2000).fit(X, all_y[idx])    accs.append((clf.predict(vec.transform(val_x)) == val_y).mean())print("TF-IDF, 1,000 labels, five different training draws: validation accuracy",      [round(float(a), 3) for a in accs], f"| spread {min(accs):.3f}-{max(accs):.3f}")
Output
TF-IDF, 1,000 labels, five different training draws: validation accuracy [0.846, 0.853, 0.84, 0.851, 0.859] | spread 0.840-0.859

Validation accuracy ranges from 0.840 to 0.859 across the five draws, about two points, purely from which 1,000 reviews were labelled. Fine-tuning runs also vary with initialisation, shuffling and dropout. So differences of one or two points between single runs, like LoRA's 0.880 against full fine-tuning's 0.860 on validation, shouldn't be read as real without repeated runs or a paired test. The paired test said there was no difference on test (Lesson 8.5).

7.2 Tracking experiments

Each note saved its settings, data fingerprints, costs and results to a file. Collecting them into one table, and saving it, is the beginning of experiment tracking: a permanent, comparable record of every run. Tools such as MLflow and Weights & Biases do this at scale, but the principle is the same.

Python 7 lines
tracking = pd.DataFrame([{"note": "8.3" if n in files["8.3"]["rungs"] else ("8.4" if n == "full fine-tuning" else "8.5"),                          "method": n, "setting": json.dumps(methods[n].get("settings", methods[n].get("setting", ""))),                          "val_acc": methods[n]["val"]["accuracy"], "test_acc": methods[n]["test"]["accuracy"]} for n in methods])os.makedirs("results", exist_ok=True)tracking.to_csv("results/experiments.csv", index=False)print(tracking[["note", "method", "val_acc", "test_acc"]].to_string(index=False))print("environment:", {"python": platform.python_version(), "torch": torch.__version__, "transformers": transformers.__version__, "peft": peft.__version__})
Output
note                                  method  val_acc  test_acc
 8.3                          majority class    0.505     0.485
 8.3                    TF-IDF, 1,000 labels    0.866     0.828
 8.3                      TF-IDF, all labels    0.921     0.913
 8.3              SST-2 model, off the shelf    0.888     0.873
 8.3 frozen DistilBERT + logistic regression    0.850     0.837
 8.4                        full fine-tuning    0.860     0.834
 8.5                                    LoRA    0.880     0.834
environment: {'python': '3.12.15', 'torch': '2.14.1', 'transformers': '5.20.1', 'peft': '0.21.2'}

The table lists every method with its note, settings, validation and test accuracy, and is saved as results/experiments.csv, alongside the environment (Python 3.12.15, PyTorch 2.14.1, Transformers 5.20.1, PEFT 0.21.2). With the fixed split, the fingerprints and the pinned versions in requirements.txt, anyone can rerun these lessons and check the numbers.

Key terms

  • Run-to-run variation: differences in results caused by randomness in data sampling, initialisation and training order.
  • Experiment tracking: recording each run's settings, data, code versions and results so that runs can be compared and reproduced.
Checkpoint Before you move on from §7

What does repeated TF-IDF training on different samples measure?

8

The Keep-or-Reject Decision

A decision should follow rules written before looking at which model looks best. Otherwise it's easy to find reasons to keep the model you hoped would win. Ours, stated in the cell below before the decision is computed:

  • R1: an adapted model must beat the strongest baseline that uses no more labels, on the same test reviews, with sign-test p < 0.05.
  • R2: it must fit the deployment budget (CPU inference, one model per task). All candidates do.
  • R3: it must not damage abilities we still need. Word-guessing isn't needed here.
Python 14 lines
rules = {    "R1": "it must beat the strongest baseline that uses no more labels, on the same test reviews, with sign-test p < 0.05",    "R2": "it must fit the deployment budget: CPU inference, one model per task",    "R3": "it must not damage abilities we still need (we do not need word-guessing here)",}ref = np.array(methods[best_baseline]["test_pred"]) == test_ydecision = {}for name in ("full fine-tuning", "LoRA"):    right = np.array(methods[name]["test_pred"]) == test_y    a, b = int((right & ~ref).sum()), int((~right & ref).sum())    p = (binomtest(a, a + b).pvalue if a + b else 1.0)    r1 = a > b and p < 0.05    decision[name] = "keep" if r1 else "reject"    print(f"{name:<17} R1: {'passes' if r1 else 'fails'} ({a} vs {b}, p = {p:.2g}) -> {decision[name]}")
Output
full fine-tuning  R1: fails (50 vs 89, p = 0.0012) -> reject
LoRA              R1: fails (43 vs 82, p = 0.00062) -> reject

Both adapted models fail R1: each is significantly worse than the off-the-shelf SST-2 model, not better. The decision is to reject both and, under a 1,000-label budget, use the SST-2 model as it is. If all 22,404 labels can be used, TF-IDF with all labels is better still.

Rejecting a model you spent effort on is not a failed project. It's the evaluation doing its job. The experiments answered a real question ("does adapting DistilBERT on 1,000 IMDb labels beat what we can get for free?") with a clear, defensible "no, not in this setup". And the error analysis showed what might change the answer.

Checkpoint Before you move on from §8

How should a keep-or-reject decision be made?

9

Documenting Findings, Limitations and Next Steps

Finally, we write the decision down, extending Lesson 7.7's decision record with everything this module measured:

Python 18 lines
record = {    "question": "Does adapting DistilBERT on 1,000 labelled IMDb reviews beat the alternatives?",    "data": {"split_fingerprints": expected, "train_subset_fingerprint": fingerprint(train_x),             "test_reviews": len(test_y)},    "results_test_accuracy": {n: methods[n]["test"]["accuracy"] for n in methods},    "decision": {**decision, "use instead (1,000-label budget)": best_baseline + " (no IMDb labels needed)",                 "if all 22,404 labels can be used": "TF-IDF, all labels (best test accuracy, beats SST-2 with p = 0.0015)"},    "limitations": ["single training run per adapted model (no seed repeats)",                    "1,000 training reviews, 1 epoch, 128 tokens (head+tail)",                    "CPU-sized experiment; learning rates not tuned on validation"],    "next steps": ["fine-tune with more labels (TF-IDF with all labels reached the best test accuracy)",                   "longer inputs (Note 7.7 found 512 tokens better than 256 for the SST-2 model)",                   "repeat training over several seeds before claiming small differences",                   "confirm any new winner on a fresh test sample, since this one has now been used for the decision"],}with open("results/8.7-decision.json", "w") as f:    json.dump(record, f, indent=1)print(json.dumps(record, indent=1))

The record states the question, the exact data (by fingerprint), every method's test accuracy, the decision with its alternative, the limitations that bound the conclusion, and next steps that could change it:

  • Limitations: a single run per adapted model; only 1,000 reviews, one epoch and 128 tokens; a CPU-sized experiment with learning rates not tuned on validation.
  • Next steps:
    • more labels, the clearest lever in our results;
    • longer inputs, since every 128-token method lost accuracy on long reviews;
    • repeated runs before claiming small differences;
    • a fresh test sample for any new winner, since this one has now been used to make a decision.

A reader of this record in six months can see what was concluded, how much to trust it, and what to try next.

Common mistake: writing up only the winning number. A useful record includes what was compared, on which data, what was rejected and why, what the conclusion depends on, and what would change it.

Checkpoint Before you move on from §9

What follows further selection using the reported test data?

Putting It All Together

Let's reassemble the evaluation, and with it Module 8.

We loaded the results of seven methods, checked by fingerprint that all used the same split and the same 1,000 training reviews, and asked seven questions:

  • Training behaved sensibly: the loss fell from about ln 2 to 0.436 and 0.440 in the two fine-tuning runs, and validation peaked at step 48 before dipping slightly.
  • Task metrics with 95% intervals: both adapted models scored 0.834 (0.810–0.856), with different recall profiles.
  • Error analysis: the two fine-tuned models shared 112 of their 166 errors, struggled on reviews longer than 128 tokens, and misread positive reviews full of negative words, which the SST-2 model handled.
  • Overfitting and forgetting: every method lost a little between validation and test (LoRA most, 0.046), and full fine-tuning forgot more (word-guessing loss 4.07) than LoRA (3.461).
  • Comparison: across the ladder, training more parameters on the same 1,000 labels didn't help, and every 1,000-label method was significantly worse than the off-the-shelf SST-2 model.
  • Variation: TF-IDF varied by about two points across five draws of training data; results were tracked in results/experiments.csv.
  • Decision: by rules set in advance, both adapted models were rejected: use the SST-2 model under a 1,000-label budget, or TF-IDF with all labels if they're available. The decision was recorded with its limitations and next steps.

Module 8 asked how to adapt pretrained models without updating every parameter, and how to prepare data for reliable adaptation.

  • 8.1 mapped the adaptation spectrum.
  • 8.2 prepared the data (and found that keeping the end of a review mattered).
  • 8.3 built the baselines.
  • 8.4 fine-tuned fully.
  • 8.5 showed LoRA matching full fine-tuning with about 1% of the trainable parameters and less forgetting.
  • 8.6 carried the same ideas to large language models.
  • 8.7 showed how to decide, honestly, whether any of it was worth it.

The adapted models didn't win here, and knowing that, with evidence, is the outcome a careful engineer should want.

Method Labels Trained params Test (1,000) vs SST-2 off the shelf Decision
TF-IDF 1,000 29,755 0.828 worse, p = 0.0022 —
Frozen DistilBERT + head 1,000 769 0.837 worse, p = 0.0055 —
Full fine-tuning 1,000 66,955,010 0.834 worse, p = 0.0012 reject
LoRA 1,000 739,586 0.834 worse, p = 0.00062 reject
SST-2 model, off the shelf 0 0 0.873 — use (1,000-label budget)
TF-IDF 22,404 399,451 0.913 better, p = 0.0015 use if all labels available
The Verdict. Keep/reject conclusions apply to the supplied one-epoch, 1,000-label experiment and declared requirements. Future tuning needs new validation work and a fresh final test sample.

Figure 8.7.2. Keep/reject conclusions apply to the supplied one-epoch, 1,000-label experiment and declared requirements. Future tuning needs new validation work and a fresh final test sample.

Summary

  • Evaluating an adapted model answers several questions: training behaviour, metrics with uncertainty, errors, overfitting and forgetting, comparison and cost, reproducibility, and the decision itself.
  • All results files used the same split and the same 1,000 training reviews (checked by fingerprint).
  • Training curves: loss fell from about ln 2 to 0.436 and 0.440 in the two runs; validation accuracy peaked at step 48. Validation loss wasn't recorded and should be next time.
  • Metrics with 95% intervals: full fine-tuning and LoRA both 0.834 (0.810–0.856); SST-2 off the shelf 0.873 (0.851–0.892); TF-IDF with all labels 0.913 (0.894–0.929).
  • Error analysis: the fine-tuned models shared 112 of 166 errors; methods reading 128 tokens dropped on long reviews; positive reviews full of negative words fooled the fine-tuned models but not the SST-2 model.
  • Overfitting signs: validation-to-test drops of 0.013–0.046 (LoRA largest). Forgetting: 4.07 for full fine-tuning against 3.461 for LoRA (pretrained 3.381).
  • Comparison: more trained parameters on the same 1,000 labels didn't help; every 1,000-label method was significantly worse than the off-the-shelf SST-2 model; only TF-IDF with all labels beat it.
  • Variation: five TF-IDF training draws ranged 0.840–0.859 on validation. Results were tracked in results/experiments.csv.
  • Decision, by rules set in advance: reject both adapted models; use the SST-2 model (or TF-IDF with all labels). The decision record lists limitations and next steps.

Glossary

Term Meaning
Training loss Loss on the training batches.
Validation loss / accuracy Loss or accuracy on held-out data, used to monitor generalisation.
Confidence interval An interval from a method designed to cover the target quantity at a stated rate over repeated samples; this is not a probability assigned to the fixed unknown value.
Wilson interval A standard confidence interval for a proportion.
Error analysis Examining which examples a model gets wrong and why.
Error overlap The number of errors two models share.
Validation–test gap The drop from validation to test score, partly due to selecting on validation.
Paired sign test Comparing two models on the same examples by counting where only one is right.
Run-to-run variation Differences between runs caused by randomness.
Experiment tracking Recording each run's settings, data, versions and results.
Keep-or-reject rule A criterion, set in advance, for adopting a model.
Decision record A written record of a decision with its evidence, limitations and next steps.

Wrap-up

Knowledge check

Quick check

1

What must be checked before combining result files?

2

Does one retention probe measure every old capability?

3

What follows further selection using the reported test data?

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.