Deep Learning Lab
Guide
Module 7 · Pretrained Models and the Hugging Face Ecosystem Lesson 7.5

Building Applications with Pretrained Models

Classroom material

48 min · 22 code cells · 1 labs

In this lesson
Time
~48 min
Sections
10
Code cells
22
Labs
1
Figures
6

Before you start

How do we turn a pretrained model's capability into a feature that behaves sensibly on real, messy inputs?

You will be able to
  • test a classifier on clear, ambiguous, negated and out-of-distribution inputs, and explain what its confidence does and doesn't tell you;
  • generate text with a pretrained language model and compare greedy decoding, temperature, top-k and top-p;
  • build a semantic search over documents with sentence embeddings, and explain what those embeddings capture;
  • use CLIP for zero-shot image classification and text-to-image search, choosing its settings on held-out data;
  • design an inference service that validates inputs, handles errors, flags out-of-domain text and abstains when unsure;
  • evaluate an application with coverage, accuracy on answered inputs and a confusion matrix.

A model is not a product. A sentiment model that scores well on a validation set is a capability; a feature that tells a business which customer reviews to read first is an application. Between the two sits a lot of engineering. The application has to decide what inputs to accept, what to do with inputs it was never designed for, when to answer and when to say "I'm not sure", and how to measure whether the whole system, not just the model, does its job.

This lesson is the practical heart of Module 7. We build four small applications from four pretrained models, each a few lines of code thanks to Lessons 7.1–7.4: sentiment analysis of film reviews, text generation, semantic search with sentence embeddings, and zero-shot image classification and search with CLIP. For each one we look as hard at where it fails as at where it works. Then we wrap the sentiment model in a reusable service that checks its inputs, recognises text that isn't a film review, and declines to answer when unsure, and we evaluate that service the way you would evaluate a real feature.

Key question: How do we turn a pretrained model's capability into a feature that behaves sensibly on real, messy inputs?

Prerequisites

read Lessons 7.1–7.4. You'll also draw on earlier lessons: the negation test from Lesson 5.3, the toy CLIP from Lesson 5.5, and the decoding strategies from Lesson 6.6.

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

This lesson loads four models in turn: the sentiment model, distilgpt2, MiniLM and CLIP. Each is deleted once its section is finished, to keep memory use down. The first run downloads well over a gigabyte in total, so it takes a while; later runs use the cache. The usual harmless warnings appear (TqdmWarning, the unauthenticated-request message and, on a Mac, a few W… lines).

Environment and reference results. Use Python 3.12 and requirements.txt (also supplied at the root of the Module 7/8 notebook bundle). Install packages into a separate environment, restart the kernel, and run from the beginning. The saved outputs are the author's CPU reference run, with approved explanatory corrections. Seeds and artifact revisions reduce variation; numbers and timings can differ across hardware. No account or paid API is required for the public teaching models. Initial model/data downloads require internet access.

Setup 36 lines
import os, gc, gzip, time, hashlib, urllib.requestimport numpy as npimport torchimport torch.nn.functional as Fimport matplotlib.pyplot as pltfrom PIL import Imagefrom datasets import load_datasetfrom transformers import (pipeline, AutoTokenizer, AutoModel, AutoModelForCausalLM,                          CLIPModel, CLIPProcessor)torch.manual_seed(0)np.random.seed(0)device = "cuda" if torch.cuda.is_available() else "cpu"print("PyTorch", torch.__version__, "| device:", device)# Freeze the public artifacts used by the recorded experiments.MODEL_REVISIONS = {'openai/clip-vit-base-patch32': '3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268', 'distilbert/distilbert-base-uncased-finetuned-sst-2-english': '714eb0fa89d2f80546fda750413ed43d93601a13', 'sentence-transformers/all-MiniLM-L6-v2': '1110a243fdf4706b3f48f1d95db1a4f5529b4d41', 'HuggingFaceTB/SmolLM2-135M': '93efa2f097d58c2a74874c7e644dbc9b0cee75a2', 'distilbert/distilgpt2': '2290a62682d06624634c1f46a6ad5be0f47f38aa', 'HuggingFaceTB/SmolLM2-135M-Instruct': '12fd25f77366fa6b3b4b768ec3050bf629380bac', 'HuggingFaceTB/SmolLM2-360M-Instruct': 'a10cc1512eabd3dde888204e902eca88bddb4951', 'Qwen/Qwen2.5-0.5B': '060db6499f32faf8b98477b0a26969ef7d8b9987', 'Qwen/Qwen2.5-7B': 'd149729398750b98c0af14eb82c78cfe92750796', 'google-bert/bert-large-uncased': '6da4b6a26a1877e173fca3225479512db81a5e5b', 'distilbert/distilbert-base-uncased': '12040accade4e8a0f71eabdb258fecc2e7e948be', 'google-bert/bert-base-uncased': '86b5e0934494bd15c9632b12f734a8a67f723594', 'cardiffnlp/twitter-roberta-base-sentiment-latest': '3216a57f2a0d9c45a2e6c20157c20c49fb4bf9c7', 'distilbert/distilbert-base-cased-distilled-squad': '564e9b582944a57a3e586bbb98fd6f0a4118db7f', 'google/vit-base-patch16-224': '3f49326eb077187dfe1c2a2bb15fbd74e6ab91e3', 'sshleifer/distilbart-cnn-12-6': 'a4f8f3ea906ed274767e9906dbaede7531d660ff', 'textattack/distilbert-base-uncased-imdb': '5b0f46c2fc4b86bf21f0ec0409bed77ee142b332', 'google/gemma-2-2b': 'c5ebcd40d208330abc697524c919956e692655cf', 'meta-llama/Llama-3.2-1B': '4e20de362430cd3b72f300e6b0f18e50e7166e08'}DATASET_REVISIONS = {'stanfordnlp/imdb': 'e6281661ce1c48d982bc483cf8a173c1bbeb5d31', 'stanfordnlp/sst2': '8d51e7e4887a4caaa95b3fbebbf53c0490b58bbb'}def benchmark_clock():    """Read elapsed time after synchronising any queued CUDA work."""    if torch.cuda.is_available():        torch.cuda.synchronize()    import time    return time.perf_counter()def ordered_fingerprint(texts, labels):    """Hash ordered text/label pairs to verify alignment for paired comparisons.    Arguments:        texts: Text strings in the order expected by the caller.        labels: Integer targets aligned with the input examples.    """    import json, hashlib    rows = [[str(t), int(y)] for t, y in zip(texts, labels)]    return hashlib.sha256(json.dumps(rows, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
Warnings / log
/Users/solomonpromise/Documents/Simplilearn Classes/Deep Learning/_work78/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
W1017 13:44:59.822000 77057 site-packages/torch/distributed/elastic/multiprocessing/redirects.py:35] NOTE: Redirects are currently not supported in MacOs.
W1017 13:44:59.908000 77057 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 13:44:59.927000 77057 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: Turning Model Capabilities into Features

Every application in this lesson has the same structure. A pretrained model supplies one capability, a function from an input to an output. The application wraps it with everything a user needs:

  • Input handling: what counts as a valid input, and what happens to one that isn't (empty text, the wrong type, a 100-page document).
  • Fit checks: is this input the kind of thing the model was built for? A film-review classifier will happily score a password-reset request; the application should notice.
  • Decision rules: when to answer, and when to hold back, for example by passing uncertain cases to a person.
  • Evaluation: measured on the whole system, with the numbers a product owner cares about: how often it answers, and how often it's right when it does.
From Capability to Feature. An application adds input validation, decision rules, error handling and measured quality to a pretrained capability. The OOD check is a heuristic to validate, not a guarantee.

Figure 7.5.1. An application adds input validation, decision rules, error handling and measured quality to a pretrained capability. The OOD check is a heuristic to validate, not a guarantee.

All the IMDb work uses the fixed split from Lesson 7.1. The function is copied here, and the fingerprints confirm the same reviews.

Python 49 lines
def load_imdb_split(seed=0, n_val=2500, n_eval=1000, verbose=True):    """Load the declared IMDb revision, remove exact duplicates and construct the seeded course splits.    Arguments:        seed: Seed controlling the local split or sampling generator.        n_val: Number of training-half reviews reserved for validation.        n_eval: Size of the reproducible evaluation subset.        verbose: Whether to print dataset audit counts.    """    ds = load_dataset("stanfordnlp/imdb", cache_dir="data/imdb",                      data_files={"train": "plain_text/train-*.parquet",                                  "test":  "plain_text/test-*.parquet"}, revision=DATASET_REVISIONS.get("stanfordnlp/imdb"))    def dedup(texts, labels, seen):        """Keep the first occurrence of each exact text not already in the supplied seen set.        Arguments:            texts: Text strings in the order expected by the caller.            labels: Integer targets aligned with the input examples.            seen: Set of exact texts already retained.        """        keep = []        for i, t in enumerate(texts):            if t not in seen:                              # keep only the first copy of each review                seen.add(t); keep.append(i)        return [texts[i] for i in keep], np.array([labels[i] for i in keep])    tr_x, tr_y = dedup(list(ds["train"]["text"]), list(ds["train"]["label"]), set())    te_x, te_y = dedup(list(ds["test"]["text"]), list(ds["test"]["label"]), set(tr_x))  # also drops test reviews already in train    if verbose:        print(f"official split: {len(ds['train'])} train, {len(ds['test'])} test reviews")        print(f"duplicates removed: {len(ds['train']) - len(tr_x)} from train, "              f"{len(ds['test']) - len(te_x)} from test (repeats, or reviews that are also in train)")    clean = lambda xs: [x.replace("<br />", " ") for x in xs]                         # remove HTML line breaks    rng = np.random.default_rng(seed)    p = rng.permutation(len(tr_x)); v, t = p[:n_val], p[n_val:]                       # validation is carved from train    q = rng.permutation(len(te_x))[:n_eval]                                           # a fixed sample of the test set    split = {"train": (clean([tr_x[i] for i in t]), tr_y[t]),             "val":   (clean([tr_x[i] for i in v]), tr_y[v]),             "test":  (clean(te_x), te_y)}    split["val_eval"] = (split["val"][0][:n_eval], split["val"][1][:n_eval])    split["test_eval"] = (clean([te_x[i] for i in q]), te_y[q])    return splitdef fingerprint(texts):    """Hash sorted texts to identify membership; this does not check row order or labels.    Arguments:        texts: Text strings in the order expected by the caller.    """    return hashlib.sha256("\n".join(sorted(texts)).encode()).hexdigest()[:12]
Python 3 lines
imdb = load_imdb_split()for name, (x, y) in imdb.items():    print(f"{name:<9} {len(x):>6} reviews | positive share {y.mean():.3f} | fingerprint {fingerprint(x)}")
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
Checkpoint Before you move on from §1

What turns a model capability into a usable feature?

2

Practice: IMDb Sentiment Classification

2.1 The aggregate

We start from the sentiment model and measure it on the 1,000 val_eval reviews. The helper positive_prob returns each text's probability of being positive. It sorts texts by length before batching, to cut padding (Lesson 7.4, Section 6), and puts the results back in the original order.

Python 19 lines
SST2 = "distilbert/distilbert-base-uncased-finetuned-sst-2-english"clf = pipeline("text-classification", model=SST2, device=device, revision=MODEL_REVISIONS.get(SST2))def positive_prob(texts, batch_size=32):    """Return the sentiment model probability assigned to positive, restoring original input order.    Arguments:        texts: Text strings in the order expected by the caller.        batch_size: Maximum examples processed together.    """    order = np.argsort([len(t) for t in texts])                       # similar lengths together: less padding (Lesson 7.4)    out = clf([texts[i] for i in order], truncation=True, max_length=256, batch_size=batch_size)    p = np.empty(len(texts))    p[order] = [o["score"] if o["label"] == "POSITIVE" else 1 - o["score"] for o in out]   # back to the original order    return p                                                          # P(positive) for each textval_x, val_y = imdb["val_eval"]val_p = positive_prob(val_x)print(f"val_eval: {len(val_x)} reviews | accuracy {((val_p > 0.5) == val_y).mean():.3f}")
Warnings / log
Loading weights:   0%|          | 0/104 [00:00<?, ?it/s]Loading weights: 100%|██████████| 104/104 [00:00<00:00, 21022.05it/s]
Output
val_eval: 1000 reviews | accuracy 0.882

The model scores 0.882 on the validation reviews. That's the aggregate. An application meets individual inputs, so let's look at those.

2.2 Individual inputs

We test five groups of inputs, written before running the model: clearly positive, clearly negative, ambiguous (mixed or lukewarm), the four negation sentences from Lesson 5.3, and inputs that are not film reviews at all.

Python 17 lines
probes = {    "positive":            ["An absolute triumph. I loved every minute of it.",                            "Gorgeous to look at and genuinely moving."],    "negative":            ["Dull, predictable and far too long.",                            "I walked out after forty minutes."],    "ambiguous":           ["The acting was superb, but the story made no sense.",                            "Not as bad as the critics said, but not good either.",                            "It was fine, I guess."],    "negation (Note 5.3)": ["the movie was good", "the movie was not good",                            "the movie was bad", "the movie was not bad"],    "out of distribution": ["asdkjh qwe zzz", "The parcel arrived on Tuesday.",                            "Le film était magnifique.", "Please reset my password."],}for group, sentences in probes.items():    print(group)    for s, p in zip(sentences, positive_prob(sentences)):        print(f"   P(positive) = {p:.3f}  {s}")
Output
positive
   P(positive) = 1.000  An absolute triumph. I loved every minute of it.
   P(positive) = 1.000  Gorgeous to look at and genuinely moving.
negative
   P(positive) = 0.000  Dull, predictable and far too long.
   P(positive) = 0.000  I walked out after forty minutes.
ambiguous
   P(positive) = 0.001  The acting was superb, but the story made no sense.
   P(positive) = 0.003  Not as bad as the critics said, but not good either.
   P(positive) = 1.000  It was fine, I guess.
negation (Note 5.3)
   P(positive) = 1.000  the movie was good
   P(positive) = 0.000  the movie was not good
   P(positive) = 0.000  the movie was bad
   P(positive) = 0.999  the movie was not bad
out of distribution
   P(positive) = 0.004  asdkjh qwe zzz
   P(positive) = 0.586  The parcel arrived on Tuesday.
   P(positive) = 0.928  Le film était magnifique.
   P(positive) = 0.011  Please reset my password.

Read the groups one at a time.

  • Clear inputs get confident, correct answers (1.000 and 0.000).
  • Negation, which defeated Lesson 5.3's model, is handled correctly. There, a bag-of-embeddings model averaged word vectors, so "not good" still looked positive. Here, "the movie was not good" gets 0.000 and "the movie was not bad" gets 0.999. A Transformer reads words in context, so "not" changes the meaning of the word after it.
  • Ambiguous inputs get confident answers too, and that's a problem. "The acting was superb, but the story made no sense" gets 0.001: the model reads the "but" clause as decisive, which is defensible. But "It was fine, I guess." gets 1.000 positive, and "Not as bad as the critics said, but not good either" gets 0.003. A human would call these lukewarm. The model, fine-tuned on sentences labelled only positive or negative, has no way to say "in between", and pushes them to an extreme with near-total confidence.
  • Out-of-distribution inputs get answers anyway. The gibberish "asdkjh qwe zzz" is called negative with P(positive) = 0.004, a confidence of 0.996. "Please reset my password." gets 0.011. Only "The parcel arrived on Tuesday." gets an uncertain-looking 0.586. (The French review gets 0.928, positive, which happens to be correct. The model was trained on English, so don't count on that: it may simply be reacting to words like "film" that look the same in both languages.)

The lesson for the application is the most important point in this lesson: a high confidence score does not mean the input made sense. The model's probabilities describe how strongly an input resembles one class compared with the other, not whether it belongs to the model's world at all. Section 6 builds a separate check for that.

Key terms

  • Capability: what a model can compute from an input, such as a probability of positive sentiment.
  • Out-of-distribution (OOD) input: an input unlike the data a model was trained on.
  • Confidence: the probability a classifier gives to its chosen class; here, the larger of P(positive) and 1 − P(positive).
Check your understandingCheck yourself
  1. The model gave "asdkjh qwe zzz" a confidence of 0.996. Why can't a two-class softmax ever say "this isn't a review"?
Checkpoint Before you move on from §2

Does high confidence establish that an input is meaningful?

3

Practice: Text Generation with a Small Pretrained Model

In Lesson 6.6 you generated Shakespeare-like text with your own small Transformer and compared decoding strategies. Here we do the same with distilgpt2, a pretrained language model trained on web text. Generating text is a loop: predict a probability for every possible next token, choose one, append it, repeat. The interesting choice is how to choose.

3.1 Temperature reshapes the distribution

First, let's look at the model's probabilities for the token after "The movie was", and how temperature changes them. Logits are divided by the temperature T before the softmax: T below 1 sharpens the distribution towards the top choices, T above 1 flattens it.

Python 12 lines
GPT = "distilbert/distilgpt2"gen_tok = AutoTokenizer.from_pretrained(GPT, revision=MODEL_REVISIONS.get(GPT))gen_model = AutoModelForCausalLM.from_pretrained(GPT, revision=MODEL_REVISIONS.get(GPT)).to(device).eval()enc = gen_tok("The movie was", return_tensors="pt").to(device)with torch.inference_mode():    next_logits = gen_model(**enc).logits[0, -1]                     # (50257,) scores for the next tokenfor T in (0.7, 1.0, 1.5):    p = (next_logits / T).softmax(-1)    top = p.topk(5)    print(f"T = {T}: " + ", ".join(f"{gen_tok.decode(int(i))!r} {v:.3f}" for v, i in zip(top.values, top.indices)),          f"| top-5 total {top.values.sum():.3f}")
Warnings / log
Loading weights:   0%|          | 0/76 [00:00<?, ?it/s]Loading weights: 100%|██████████| 76/76 [00:00<00:00, 12301.91it/s]
Output
T = 0.7: ' a' 0.148, ' released' 0.134, ' made' 0.110, ' shot' 0.108, ' the' 0.041 | top-5 total 0.540
T = 1.0: ' a' 0.067, ' released' 0.063, ' made' 0.055, ' shot' 0.054, ' the' 0.027 | top-5 total 0.267
T = 1.5: ' a' 0.017, ' released' 0.017, ' made' 0.015, ' shot' 0.015, ' the' 0.010 | top-5 total 0.074

At T = 1.0, the model's own distribution, the top choice " a" has probability 0.067, and the top five tokens together hold only 0.267. Most of the probability is spread over thousands of other tokens. At T = 0.7, the same five tokens hold 0.540; at T = 1.5, only 0.074. The order never changes, only how sharply the probability is concentrated.

3.2 Four ways to choose

We compare four strategies from Lesson 6.6, each continuing "The movie was" for 40 tokens:

  • greedy: always take the most likely token;
  • temperature 0.7: sample from the sharpened distribution;
  • top-k 40: sample from the 40 most likely tokens only;
  • top-p 0.9: sample from the smallest set of tokens whose probabilities add up to 0.9 (also called nucleus sampling).

Sampling is random, so we fix the seed before each call. That way you get the same text as these lessons.

Python 21 lines
strategies = {"greedy":          dict(do_sample=False),              "temperature 0.7": dict(do_sample=True, temperature=0.7, top_k=0),              "top-k 40":        dict(do_sample=True, top_k=40),              "top-p 0.9":       dict(do_sample=True, top_p=0.9, top_k=0)}def generate(prompt, seed, **settings):    """Generate continuation tokens under the declared decoding settings and return decoded new text.    Arguments:        prompt: Text context supplied to the generator.        seed: Seed controlling the local split or sampling generator.        **settings: Explicit settings forwarded to the operation described above.    """    torch.manual_seed(seed)                                           # sampling is random: fix the seed    enc = gen_tok(prompt, return_tensors="pt").to(device)    with torch.inference_mode():        out = gen_model.generate(**enc, max_new_tokens=40, pad_token_id=gen_tok.eos_token_id, **settings)    return gen_tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)   # new text onlyfor name, settings in strategies.items():    print(f"{name:<16} The movie was{generate('The movie was', seed=0, **settings)!r}")
Output
greedy           The movie was' a hit in the United States and was a hit in the United Kingdom.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'
temperature 0.7  The movie was' a gritty pilot with a very well-thought out story, the story that the protagonist had to live with.\n\n\n\nThe film was a tough one, however, and he had to act'
top-k 40         The movie was' a wonderful, funny and hilarious movie, directed by J.J. Abrams.\n\n\n\n"It looked like I should have finished a lot of the films in a better way," Laffer'
top-p 0.9        The movie was' a gritty pilot with pretty much every scene, the scenes are the same, the stakes are fantastic and the production is scary. However, so far, it’ve been very successful.With an'

Greedy decoding produces one fluent clause ("a hit in the United States and was a hit in the United Kingdom", already repeating itself), then gets stuck emitting blank lines (\n) until it runs out of tokens. Once a blank line is the most likely next token, it stays the most likely next token. The sampled versions are more varied and readable, though not exactly sensible: distilgpt2 invents a "gritty pilot" and a quote from someone called Laffer. The model has learned what film writing sounds like, not what is true (the same lesson as Lesson 7.1, Section 3).

One sample per strategy proves little, so let's measure repetition over several prompts and seeds. We count the share of 3-word sequences that repeat earlier ones in the same text: 0 means no repetition.

Python 16 lines
def repeated_trigram_share(text):    """Measure repeated three-word sequences in this generated sample; this is not a general quality metric.    Arguments:        text: One input text string.    """    words = text.split()    grams = [tuple(words[i:i + 3]) for i in range(len(words) - 2)]    return 1 - len(set(grams)) / max(len(grams), 1)                  # 0 = no 3-word sequence repeatsprompts = ["The movie was", "My favourite film of all time is", "The director's latest work"]for name, settings in strategies.items():    seeds = [0] if name == "greedy" else range(3)                    # greedy is the same for every seed    shares = [repeated_trigram_share(generate(p, s, **settings)) for p in prompts for s in seeds]    print(f"{name:<16} repeated 3-word sequences: mean {np.mean(shares):.2f} | min {min(shares):.2f} | max {max(shares):.2f} | {len(shares)} texts")del gen_model; _ = gc.collect()
Output
greedy           repeated 3-word sequences: mean 0.18 | min 0.00 | max 0.30 | 3 texts
temperature 0.7  repeated 3-word sequences: mean 0.00 | min 0.00 | max 0.00 | 9 texts
top-k 40         repeated 3-word sequences: mean 0.00 | min 0.00 | max 0.00 | 9 texts
top-p 0.9        repeated 3-word sequences: mean 0.00 | min 0.00 | max 0.00 | 9 texts

Greedy decoding repeats itself in its texts (a mean of 0.18, up to 0.30), while none of the 9 sampled texts for any sampling strategy repeats a 3-word sequence. (Our measure counts words, so greedy's runs of blank lines aren't even included; its real repetition is worse.) For open-ended writing, sampling with a moderate temperature, top-k or top-p is the usual choice. For tasks with one right answer, such as extracting a date, greedy is often better, because variety is not wanted.

Key terms

  • Temperature: a number that divides the logits before softmax; below 1 sharpens the distribution, above 1 flattens it.
  • Top-k sampling: sampling only from the k most likely tokens.
  • Top-p (nucleus) sampling: sampling only from the smallest set of tokens whose probabilities sum to at least p.
  • Degenerate repetition: a generator getting stuck repeating the same words or tokens.

Common mistake: comparing decoding strategies on a single sample each. Sampling is random: one lucky or unlucky sample says little. Fix the seeds and compare several prompts and samples.

Check your understandingCheck yourself
  1. Why does greedy decoding get stuck in loops, while sampling usually escapes them?
Checkpoint Before you move on from §3

What does temperature change?

4

Practice: Pretrained Embeddings for Semantic Similarity

4.1 What the embeddings capture

A sentence-embedding model turns a whole text into one vector, so that texts with similar meanings get nearby vectors. We use all-MiniLM-L6-v2 (Lesson 7.3), with the pooling it was trained for: the average of its token vectors, ignoring padding. Each vector is then scaled to length 1, so a dot product between two vectors equals their cosine similarity.

Python 22 lines
MINILM = "sentence-transformers/all-MiniLM-L6-v2"emb_tok = AutoTokenizer.from_pretrained(MINILM, revision=MODEL_REVISIONS.get(MINILM))emb_model = AutoModel.from_pretrained(MINILM, revision=MODEL_REVISIONS.get(MINILM)).to(device).eval()def embed(texts, batch_size=64, max_length=128):    """Mean-pool nonpadding token vectors and return unit-length sentence vectors.    Arguments:        texts: Text strings in the order expected by the caller.        batch_size: Maximum examples processed together.        max_length: Token budget including special tokens.    """    out = []    with torch.inference_mode():        for i in range(0, len(texts), batch_size):            enc = emb_tok(texts[i:i + batch_size], truncation=True, max_length=max_length,                          padding=True, return_tensors="pt").to(device)            tokens = emb_model(**enc).last_hidden_state                  # (B, T, 384)            mask = enc["attention_mask"].unsqueeze(-1).float()           # (B, T, 1): 1 = real token            mean = (tokens * mask).sum(1) / mask.sum(1)                  # (B, 384): average of real tokens            out.append(F.normalize(mean, dim=-1).cpu())                  # length 1, so dot product = cosine    return torch.cat(out)
Warnings / log
Loading weights:   0%|          | 0/103 [00:00<?, ?it/s]Loading weights: 100%|██████████| 103/103 [00:00<00:00, 8681.59it/s]

In Lesson 5.3 we found that embeddings learn what their objective rewards: vectors trained to predict sentiment grouped words by sentiment, while vectors trained to predict neighbouring words grouped them by topic. Let's ask what MiniLM's vectors reward. We compare one sentence with four others:

Python 8 lines
anchor = "A thrilling, beautifully acted film."others = {"paraphrase":                       "An exciting movie with superb performances.",          "same topic, opposite sentiment":   "A dull, badly acted film.",          "same sentiment, different topic":  "A delicious, beautifully cooked meal.",          "unrelated":                        "The parcel arrived on Tuesday."}vecs = embed([anchor] + list(others.values()))for (kind, text), v in zip(others.items(), vecs[1:]):    print(f"{kind:<33} cosine {float(vecs[0] @ v):.3f}  {text}")
Output
paraphrase                        cosine 0.734  An exciting movie with superb performances.
same topic, opposite sentiment    cosine 0.737  A dull, badly acted film.
same sentiment, different topic   cosine 0.344  A delicious, beautifully cooked meal.
unrelated                         cosine -0.007  The parcel arrived on Tuesday.

The paraphrase scores 0.734, as expected. But "A dull, badly acted film" scores 0.737, as high or higher, even though it says the opposite. Meanwhile "A delicious, beautifully cooked meal", which shares the positive sentiment and even the word "beautifully", scores only 0.344. MiniLM was trained to bring together texts about the same thing, such as a question and its answer, or two descriptions of the same event. So its vectors capture topic far more than opinion. That's exactly what search needs, but it means these embeddings, used as they are, are a poor way to measure sentiment.

Search is the classic use: embed a collection of documents once, embed each query, and return the documents whose vectors are closest. We embed the first 1,000 training reviews and try three queries written before running.

Python 11 lines
corpus_x, corpus_y = imdb["train"][0][:1000], imdb["train"][1][:1000]    # the first 1,000 training reviewscorpus = embed(corpus_x)                                                   # (1000, 384)print("corpus:", tuple(corpus.shape))queries = ["a heartwarming story about a family",           "terrible special effects and wooden acting",           "a horror film that kept me awake at night"]for q, qv in zip(queries, embed(queries)):    scores = corpus @ qv                                                   # (1000,) cosine with every review    print(f"\nquery: {q!r}")    for i in scores.topk(3).indices.tolist():        print(f"  {scores[i]:.3f} | {'pos' if corpus_y[i] else 'neg'} | {corpus_x[i][:110]}...")
Output
corpus: (1000, 384)

query: 'a heartwarming story about a family'
  0.482 | pos | This film is moving without being sentimental - meaningful without being pretentious. It tells a simple story ...
  0.444 | pos | This is arguably John Thaw's finest performance where he successfully shakes off any traits of his Inspector M...
  0.440 | pos | The autobiography on which this movie is based remains one of the most heart-rending books I have ever read. I...

query: 'terrible special effects and wooden acting'
  0.648 | neg | Where to begin? the special effects should be named special defects, When the director shouted "action" I gues...
  0.595 | neg | Budget, decent actors ...who knew these things were important. Don't waste your time on this piece of junk. Th...
  0.590 | pos | I just got this video used and I was watching it last night. The acting started out extremely bad (hey------he...

query: 'a horror film that kept me awake at night'
  0.604 | pos | My personal favorite horror film. From the lengthy first tracking shot to the final story twist, this is Carpe...
  0.542 | neg | Oh my, where to start... this movie was just awful. The writing was bad, acting was bad, special effects were ...
  0.537 | neg | Wow...sheer brilliance.  Turning a thriller/suspense/horror into comedy.  After watching this, I never laughed...

The results match the meaning of the queries, not their exact words. The best match for "terrible special effects and wooden acting" says the special effects "should be named special defects" without using the word "terrible". For "a heartwarming story about a family", all three are moving, positive reviews. As in Section 4.1, the search finds topics, not opinions: the horror query returns one review that loved its film and two that hated theirs, and one of the "terrible special effects" results is a positive review that mentions bad acting at the start.

Key terms

  • Sentence-embedding model: a model that maps a text to one vector, placing similar meanings close together.
  • Mean pooling: averaging a text's token vectors (ignoring padding) to get one vector for the whole text.
  • Semantic search: retrieving documents by similarity of meaning, using embeddings, rather than by matching words.
Check your understandingCheck yourself
  1. A colleague wants to use MiniLM similarity to find reviews that "agree" with a given review. Based on Section 4.1, what would go wrong?
Checkpoint Before you move on from §4

Does high semantic similarity guarantee matching sentiment?

5

Practice: CLIP for Zero-Shot Image Classification and Text-to-Image Retrieval

5.1 From the toy to the real thing

In Lesson 5.5 you built a toy version of CLIP: an image encoder and a text encoder trained so that matching image–caption pairs had similar vectors. It worked, but it could only recognise what its small training set described. Trained without sneakers, it matched sneaker images to "sandal" and "ankle boot". The real CLIP was trained the same way on an enormous collection of images and captions from the web. That lets it do zero-shot classification: we write each class as a sentence ("a photo of a sneaker."), embed the sentences, and give each image the class whose sentence is most similar. No training on our classes at all.

We use the Fashion-MNIST test file from Module 5. Fashion-MNIST images are tiny (28 × 28), greyscale and drawn light-on-dark, very unlike CLIP's web photos, so two settings may matter: whether to invert the images to dark-on-white, and how to word the class template. To choose them fairly, we use two separate sets of images: the first 300 to choose settings, and 1,000 others to report the result.

Python 10 lines
os.makedirs("data/fashion", exist_ok=True)base = "https://raw.githubusercontent.com/zalandoresearch/fashion-mnist/master/data/fashion/"for f in ["t10k-images-idx3-ubyte.gz", "t10k-labels-idx1-ubyte.gz"]:    if not os.path.exists(f"data/fashion/{f}"):        urllib.request.urlretrieve(base + f, f"data/fashion/{f}")images = np.frombuffer(gzip.open("data/fashion/t10k-images-idx3-ubyte.gz").read(), np.uint8, offset=16).reshape(-1, 28, 28)targets = np.frombuffer(gzip.open("data/fashion/t10k-labels-idx1-ubyte.gz").read(), np.uint8, offset=8)classes = ["T-shirt/top", "trouser", "pullover", "dress", "coat", "sandal", "shirt", "sneaker", "bag", "ankle boot"]choose_idx, test_idx = np.arange(0, 300), np.arange(1000, 2000)          # two disjoint sets of imagesprint(images.shape, "| choosing on", len(choose_idx), "images | reporting on", len(test_idx), "images")
Output
(10000, 28, 28) | choosing on 300 images | reporting on 1000 images
Python 28 lines
CLIP = "openai/clip-vit-base-patch32"clip = CLIPModel.from_pretrained(CLIP, revision=MODEL_REVISIONS.get(CLIP)).to(device).eval()               # downloads about 605 MB the first timeproc = CLIPProcessor.from_pretrained(CLIP, revision=MODEL_REVISIONS.get(CLIP))def image_vectors(idx, invert):    """Preprocess Fashion-MNIST images, apply the CLIP image encoder and normalise projected features.    Arguments:        idx: Indices selecting examples from the declared collection.        invert: Whether to reverse grayscale polarity before CLIP preprocessing.    """    vecs = []    with torch.inference_mode():        for i in range(0, len(idx), 100):            pics = [Image.fromarray(255 - images[j] if invert else images[j]).convert("RGB") for j in idx[i:i + 100]]            out = clip.get_image_features(**proc(images=pics, return_tensors="pt").to(device))            vecs.append(F.normalize(out.pooler_output, dim=-1).cpu())      # (100, 512) in the shared space    return torch.cat(vecs)def text_vectors(texts):    """Encode candidate descriptions with CLIP and normalise the projected text features.    Arguments:        texts: Text strings in the order expected by the caller.    """    with torch.inference_mode():        out = clip.get_text_features(**proc(text=texts, return_tensors="pt", padding=True).to(device))    return F.normalize(out.pooler_output, dim=-1).cpu()                     # (len(texts), 512)
Warnings / log
Loading weights:   0%|          | 0/398 [00:00<?, ?it/s]Loading weights: 100%|██████████| 398/398 [00:00<00:00, 59129.11it/s]

5.2 Choosing settings on held-out images

We try both settings on the 300 choosing images and keep the best combination.

Python 10 lines
templates = ["a photo of a {}.", "a black and white photo of a {}."]choose_vecs = {inv: image_vectors(choose_idx, inv) for inv in (False, True)}results = {}for inv in (False, True):    for tpl in templates:        pred = (choose_vecs[inv] @ text_vectors([tpl.format(c) for c in classes]).T).argmax(1).numpy()        results[(inv, tpl)] = (pred == targets[choose_idx]).mean()        print(f"inverted={str(inv):<5} template={tpl!r:<36} accuracy {results[(inv, tpl)]:.3f}")best_inv, best_tpl = max(results, key=results.get)print(f"chosen on the {len(choose_idx)} choosing images:", "inverted" if best_inv else "as given", "|", repr(best_tpl))
Output
inverted=False template='a photo of a {}.'                   accuracy 0.633
inverted=False template='a black and white photo of a {}.'   accuracy 0.690
inverted=True  template='a photo of a {}.'                   accuracy 0.723
inverted=True  template='a black and white photo of a {}.'   accuracy 0.750
chosen on the 300 choosing images: inverted | 'a black and white photo of a {}.'

Both changes help: inverting the images to dark-on-white, like a product photo, and describing them as black-and-white photos. The best combination scores 0.750 on the choosing images, against 0.633 for the plain setup. Now we use that combination, once, on the 1,000 reporting images:

Python 10 lines
test_vecs = image_vectors(test_idx, best_inv)class_vecs = text_vectors([best_tpl.format(c) for c in classes])test_pred = (test_vecs @ class_vecs.T).argmax(1).numpy()test_true = targets[test_idx]print(f"zero-shot accuracy on {len(test_idx)} new images: {(test_pred == test_true).mean():.3f} (chance 0.100)")for c, name in enumerate(classes):    mask = test_true == c    wrong = np.bincount(test_pred[mask & (test_pred != c)], minlength=10)    note = f" | most often mistaken for {classes[wrong.argmax()]}" if wrong.sum() else ""    print(f"  {name:<12} {mask.sum():>3} images | accuracy {(test_pred[mask] == c).mean():.2f}{note}")
Output
zero-shot accuracy on 1000 new images: 0.733 (chance 0.100)
  T-shirt/top   93 images | accuracy 0.59 | most often mistaken for dress
  trouser       98 images | accuracy 0.98 | most often mistaken for coat
  pullover     103 images | accuracy 0.91 | most often mistaken for coat
  dress         97 images | accuracy 0.48 | most often mistaken for coat
  coat         104 images | accuracy 0.65 | most often mistaken for pullover
  sandal       108 images | accuracy 0.83 | most often mistaken for ankle boot
  shirt        100 images | accuracy 0.15 | most often mistaken for pullover
  sneaker      105 images | accuracy 0.79 | most often mistaken for sandal
  bag           99 images | accuracy 0.98 | most often mistaken for pullover
  ankle boot    93 images | accuracy 0.95 | most often mistaken for sneaker

CLIP classifies 0.733 of the 1,000 new images correctly, against 0.100 by chance, with no training on Fashion-MNIST at all. The result on the reporting images is close to the 0.750 seen on the choosing images, as it should be when settings are chosen fairly.

The per-class results tell the real story:

  • Sneakers, the class Lesson 5.5's toy could not recognise, are recognised 0.79 of the time. CLIP learned what a sneaker is from the web, not from our data.
  • Trousers and bags are almost perfect (0.98). They have distinctive shapes.
  • Shirts nearly always fail (0.15), most often called pullovers, and dresses manage only 0.48. At 28 × 28 pixels in greyscale, a shirt, a T-shirt, a pullover and a coat really are hard to tell apart. Their differences (buttons, collars, fabric) are mostly lost at this size.

Because images and text share one space, we can also search images with words: embed a query sentence and return the closest images.

Python 14 lines
search = ["sneaker", "bag", "dress", "shirt"]fig, axes = plt.subplots(len(search), 5, figsize=(7, 6))for row, word in enumerate(search):    scores = test_vecs @ text_vectors([best_tpl.format(word)])[0]          # (1000,) similarity to the query    top = scores.topk(5).indices.numpy()    hits = (test_true[top] == classes.index(word)).sum()    print(f"query {best_tpl.format(word)!r}: {hits} of the top 5 are {word!r} | returned: {[classes[t] for t in test_true[top]]}")    for k, j in enumerate(top):        ax = axes[row, k]        ax.imshow(images[test_idx[j]], cmap="gray_r" if best_inv else "gray"); ax.set_xticks([]); ax.set_yticks([])        ax.set_title(classes[test_true[j]], fontsize=8, color="green" if test_true[j] == classes.index(word) else "red")    axes[row, 0].set_ylabel(f'"{word}"', fontsize=10)plt.suptitle("Text-to-image search with CLIP: top 5 of 1,000 images per query"); plt.tight_layout(); plt.show()del clip; _ = gc.collect()
Output
query 'a black and white photo of a sneaker.': 4 of the top 5 are 'sneaker' | returned: ['sneaker', 'sneaker', 'sneaker', 'sneaker', 'sandal']
query 'a black and white photo of a bag.': 4 of the top 5 are 'bag' | returned: ['bag', 'bag', 'bag', 'bag', 'shirt']
query 'a black and white photo of a dress.': 4 of the top 5 are 'dress' | returned: ['dress', 'dress', 'dress', 'T-shirt/top', 'dress']
query 'a black and white photo of a shirt.': 2 of the top 5 are 'shirt' | returned: ['T-shirt/top', 'shirt', 'T-shirt/top', 'T-shirt/top', 'shirt']
Plot output
Plot produced by the code above

For "sneaker", "bag" and "dress", 4 of the top 5 images are right. The mistakes are understandable: a sandal for "sneaker", a T-shirt for "dress", and a plaid shirt for "bag". For "shirt", only 2 of the top 5 are shirts and the other three are T-shirts, the same confusion the per-class numbers showed. In the dataset's labels a T-shirt and a shirt are different classes, but in everyday English the word "shirt" covers both, and CLIP learned its idea of a "shirt" from everyday English.

Key terms

  • Zero-shot classification: classifying into classes the model was never trained on, by describing each class in words.
  • Prompt template: the sentence pattern used to describe each class, such as "a photo of a {}.".
  • Text-to-image retrieval: finding the images whose embeddings are closest to a text query's embedding.

Common mistake: choosing a template or preprocessing step on the same images you report. Trying several settings and reporting the best one on the same data overstates accuracy. Choose on one set, report on another, as we did with the 300 and the 1,000.

Check your understandingCheck yourself
  1. Our zero-shot CLIP reached 0.733 on Fashion-MNIST; a small CNN trained on Fashion-MNIST in Module 5 did better. When would you still choose CLIP?
Checkpoint Before you move on from §5

What does zero-shot CLIP classification compare?

6

Designing a Reusable Inference Pipeline That Handles Errors and Invalid Inputs

6.1 Two thresholds, chosen on validation

Section 2 showed two weaknesses an application must handle: the model answers inputs that aren't film reviews, and it is sometimes unsure. We'll handle each with a rule, and set each rule's threshold on validation data before looking at the test set.

  • Review-likeness check. For any input, we compute its MiniLM embedding's similarity to the closest of our 1,000 known training reviews (Section 4). Genuine reviews resemble other reviews; a password-reset request doesn't. We set the threshold so that 99% of 500 unused validation reviews pass. An input scoring below that is flagged as out of domain.
  • Abstention. The model's confidence on the val_eval reviews from Section 2 tells us how sure it usually is. We set the threshold at the 10th percentile, so the service holds back on its least confident tenth of reviews and leaves them for a person.
Python 14 lines
ref = corpus                                                         # embeddings of 1,000 training reviews (Section 4)def review_likeness(texts):    """Return similarity to the nearest reference review; use this as a validated heuristic rather than a guarantee.    Arguments:        texts: Text strings in the order expected by the caller.    """    return (embed(texts) @ ref.T).max(dim=1).values.numpy()           # similarity to the closest known reviewood_x = imdb["val"][0][1000:1500]                                     # 500 validation reviews not used elsewhereood_threshold = float(np.quantile(review_likeness(ood_x), 0.01))      # 99% of genuine reviews score above thisconfidence = np.maximum(val_p, 1 - val_p)                             # confidence on val_eval (Section 2)conf_threshold = float(np.quantile(confidence, 0.10))                 # abstain on the least confident 10%print(f"review-likeness threshold: {ood_threshold:.3f} | confidence threshold: {conf_threshold:.3f}")
Output
review-likeness threshold: 0.364 | confidence threshold: 0.948

Genuine reviews almost all score above 0.364 in review-likeness, and the confidence threshold is 0.948. Both were set without touching the test set.

6.2 The service

The class below wraps everything. It returns one result per input, as a dictionary with a status: ok (with a label), abstain (unsure), out_of_domain (not a film review), rejected (invalid input, with the reason) or error (the model failed). The design choices worth copying:

  • Check inputs before the model sees them. Wrong types, empty or whitespace-only text, and absurdly long text are rejected with a reason, instead of crashing or wasting computation.
  • Never let one bad input break a batch. Invalid items are set aside, the rest are processed together, and a model failure is caught and reported as a status rather than raised.
  • Return reasons, not just answers. A caller can show "low confidence" or "not a film review" to a user, or route the item to a person.
  • Keep the thresholds as settings, so they can be re-tuned without changing code.
Python 59 lines
class SentimentService:    """Wrap sentiment inference with per-input validation, an out-of-domain heuristic and confidence abstention."""    def __init__(self, classify, likeness, ood_threshold, conf_threshold, max_chars=20_000):        """Initialise the components and settings used by this model or service.        Arguments:            classify: Callable returning positive-class scores.            likeness: Callable returning review-likeness scores.            ood_threshold: Validation-selected cutoff for the review-likeness heuristic.            conf_threshold: Validation-selected cutoff for answering confidently.            max_chars: Maximum accepted input length in characters.        """        self.classify, self.likeness = classify, likeness        self.ood_threshold, self.conf_threshold, self.max_chars = ood_threshold, conf_threshold, max_chars    def check(self, text):        """Return a rejection reason for invalid input, or None for a permitted text.        Arguments:            text: One input text string.        """        if not isinstance(text, str):            return f"expected text, got {type(text).__name__}"        if not text.strip():            return "empty text"        if len(text) > self.max_chars:            return f"text longer than {self.max_chars:,} characters"        return None    def predict(self, texts):        """Run the declared model or service and return predictions in the original example order.        Arguments:            texts: Text strings in the order expected by the caller.        """        results = [{"status": "rejected", "reason": self.check(t)} for t in texts]        ok = [i for i, r in enumerate(results) if r["reason"] is None]        if not ok:            return results        try:            p_pos = self.classify([texts[i] for i in ok])            like = self.likeness([texts[i] for i in ok])        except Exception as e:                                        # a model failure must not crash the caller            for i in ok:                results[i] = {"status": "error", "reason": type(e).__name__}            return results        for i, p, s in zip(ok, p_pos, like):            r = {"label": "POSITIVE" if p > 0.5 else "NEGATIVE", "confidence": float(max(p, 1 - p)),                 "review_likeness": float(s), "reason": None}            if s < self.ood_threshold:                r.update(status="out_of_domain", reason="does not look like a movie review")            elif r["confidence"] < self.conf_threshold:                r.update(status="abstain", reason="low confidence")            else:                r["status"] = "ok"            results[i] = r        return resultsservice = SentimentService(positive_prob, review_likeness, ood_threshold, conf_threshold)

Let's try it on a mix of inputs: good reviews, the problem inputs from Section 2, and invalid ones.

Python 9 lines
inputs = ["An absolute triumph. I loved every minute of it.",          "The acting was superb, but the story made no sense.",          "the movie was not bad",          "asdkjh qwe zzz", "The parcel arrived on Tuesday.", "Please reset my password.",          "", "   ", None, 42, "x" * 25_000]for text, r in zip(inputs, service.predict(inputs)):    shown = repr(text) if not isinstance(text, str) or len(text) < 60 else repr(text[:20] + "...")    tidy = {k: round(v, 3) if isinstance(v, float) else v for k, v in r.items()}   # rounded for display only    print(f"{shown:<56} {tidy}")
Output
'An absolute triumph. I loved every minute of it.'       {'label': 'POSITIVE', 'confidence': 1.0, 'review_likeness': 0.482, 'reason': None, 'status': 'ok'}
'The acting was superb, but the story made no sense.'    {'label': 'NEGATIVE', 'confidence': 0.999, 'review_likeness': 0.607, 'reason': None, 'status': 'ok'}
'the movie was not bad'                                  {'label': 'POSITIVE', 'confidence': 0.999, 'review_likeness': 0.607, 'reason': None, 'status': 'ok'}
'asdkjh qwe zzz'                                         {'label': 'NEGATIVE', 'confidence': 0.996, 'review_likeness': 0.238, 'reason': 'does not look like a movie review', 'status': 'out_of_domain'}
'The parcel arrived on Tuesday.'                         {'label': 'POSITIVE', 'confidence': 0.586, 'review_likeness': 0.16, 'reason': 'does not look like a movie review', 'status': 'out_of_domain'}
'Please reset my password.'                              {'label': 'NEGATIVE', 'confidence': 0.989, 'review_likeness': 0.141, 'reason': 'does not look like a movie review', 'status': 'out_of_domain'}
''                                                       {'status': 'rejected', 'reason': 'empty text'}
'   '                                                    {'status': 'rejected', 'reason': 'empty text'}
None                                                     {'status': 'rejected', 'reason': 'expected text, got NoneType'}
42                                                       {'status': 'rejected', 'reason': 'expected text, got int'}
'xxxxxxxxxxxxxxxxxxxx...'                                {'status': 'rejected', 'reason': 'text longer than 20,000 characters'}

Every input gets a sensible outcome. The two clear reviews and "the movie was not bad" are answered. The gibberish, the parcel and the password request are all flagged out_of_domain, with review-likeness scores of 0.238, 0.16 and 0.141, below the 0.364 threshold. The sentiment model alone had been 0.996 and 0.989 sure about two of them. Empty and whitespace-only text, None, a number and a 25,000-character string are rejected with clear reasons, and none of them crashed anything.

Notice one remaining weakness: "The acting was superb, but the story made no sense." is answered NEGATIVE with confidence 0.999. Abstention only catches uncertainty the model admits; it can't catch confident mistakes on genuinely mixed reviews.

Key terms

  • Inference service: code that wraps a model with input checks, decision rules and error handling, and returns structured results.
  • Abstention: declining to answer an input, typically when confidence is low, so that a person or another system can handle it.
  • Out-of-domain detection: checking whether an input resembles the data a model was built for.
  • Threshold: the cut-off value in a decision rule; set it on validation data.
Check your understandingCheck yourself
  1. Why did we set the review-likeness threshold using genuine reviews only, rather than a mix of reviews and non-reviews?

Interpret the evidence carefully. Review-likeness is a heuristic demonstrated on these inputs, not a guarantee of robust out-of-domain detection. Validate false accepts and false rejects on representative in-domain and out-of-domain examples before deploying it.

Visual walkthrough: Trace three service decisions.

Trace three service decisions; exact steps are described below.

1. Validate the input. Empty input → reject. Validation happens before model inference. A model score cannot repair an invalid request.

2. Check the declared domain. Flagged out of domain → hold back. This course uses a review-likeness heuristic, which can falsely accept or reject inputs.

3. Apply a confidence threshold. Illustration: 0.88 ≥ 0.80 → answer. These are invented decision-rule values, not a measurement of model calibration.

4. Abstain when required. Illustration: 0.63 < 0.80 → abstain. The source run answers 909, abstains on confidence for 79, and flags 12 as out of domain. All 91 non-answered cases form the held-back group.

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

Interactive

Trace three service decisions

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

Where should service thresholds be chosen?

7

Evaluating Application-Level Performance

7.1 What the service does on unseen reviews

Now the test set, used once. We run the service on the 1,000 test_eval reviews. With 1,000 reviews, each review is worth 0.1 percentage points of accuracy.

Python 12 lines
test_x, test_y = imdb["test_eval"]test_results = service.predict(test_x)status = np.array([r["status"] for r in test_results])pred = np.array([r["label"] == "POSITIVE" for r in test_results])conf = np.array([r["confidence"] for r in test_results])for s in ("ok", "abstain", "out_of_domain", "rejected", "error"):    print(f"{s:<14} {(status == s).sum():>4}")answered = status == "ok"print(f"\naccuracy if we answered everything: {(pred == test_y).mean():.3f} ({len(test_y)} reviews; one review = {1 / len(test_y):.1%})")print(f"coverage (share answered):          {answered.mean():.3f}")print(f"accuracy on the answered reviews:   {(pred[answered] == test_y[answered]).mean():.3f}")print(f"accuracy on the held-back reviews:  {(pred[~answered] == test_y[~answered]).mean():.3f}")
Output
ok              909
abstain          79
out_of_domain    12
rejected          0
error             0

accuracy if we answered everything: 0.875 (1000 reviews; one review = 0.1%)
coverage (share answered):          0.909
accuracy on the answered reviews:   0.899
accuracy on the held-back reviews:  0.637

The service answered 909 reviews, abstained on 79 and flagged 12 genuine reviews as out of domain: about 1%, matching the rate we designed on validation. Three numbers describe the application:

  • Accuracy if we answered everything: 0.875. This is the model's raw accuracy.
  • Coverage: 0.909 of reviews were answered.
  • Accuracy on the answered reviews: 0.899.

The held-back reviews really were the hard ones: the model would have got only 0.637 of them right. Abstention bought a higher accuracy on what we do answer at the cost of answering fewer reviews, and that is a product decision, not a technical one.

7.2 Which way the errors go

Accuracy hides which mistakes a model makes. A confusion matrix counts each combination of true and predicted label:

Python 5 lines
cm = np.zeros((2, 2), dtype=int)for t, p_ in zip(test_y, pred.astype(int)):    cm[t, p_] += 1print("rows = true label, columns = predicted (0 = negative, 1 = positive), all 1,000 reviews:\n", cm)print(f"recall on negative reviews {cm[0, 0] / cm[0].sum():.3f} | recall on positive reviews {cm[1, 1] / cm[1].sum():.3f}")
Output
rows = true label, columns = predicted (0 = negative, 1 = positive), all 1,000 reviews:
 [[434  51]
 [ 74 441]]
recall on negative reviews 0.895 | recall on positive reviews 0.856

Of 485 negative reviews (434 + 51), 434 were recognised; of 515 positive reviews (74 + 441), 441 were. The model misses positive reviews more often than negative ones: a recall of 0.856 for positive against 0.895 for negative. If the feature exists to surface unhappy customers, missing 51 negative reviews matters more than the positive mistakes. If it exists to quote praise on a poster, the 74 missed positives matter more. The same model can be good enough for one feature and not the other.

7.3 The coverage–accuracy trade-off

Our threshold is one point on a continuum. If we rank the test reviews from most to least confident and answer only the top share, we get a curve:

Python 14 lines
order = np.argsort(-conf, kind="stable")                              # most confident firstright = (pred == test_y)[order]coverage = np.arange(1, len(order) + 1) / len(order)acc_at = np.cumsum(right) / np.arange(1, len(order) + 1)              # accuracy if we answer the top-k most confidentplt.figure(figsize=(6.2, 3.8))plt.plot(coverage, acc_at)plt.axvline(answered.mean(), color="grey", ls="--", label=f"our service (thresholds set on validation)")plt.axhline((pred == test_y).mean(), color="orange", ls=":", label="answer everything")plt.xlabel("coverage: share of reviews answered (most confident first)"); plt.ylabel("accuracy on answered reviews")plt.ylim(0.85, 1.0); plt.legend(fontsize=8); plt.grid(alpha=0.3); plt.title("Trading coverage for accuracy (test_eval)")plt.show()for c in (0.5, 0.8, 0.9, 1.0):    k = int(c * len(order))    print(f"answer the {c:.0%} most confident: accuracy {acc_at[k - 1]:.3f}")
Plot output
Plot produced by the code above
Output
answer the 50% most confident: accuracy 0.984
answer the 80% most confident: accuracy 0.932
answer the 90% most confident: accuracy 0.899
answer the 100% most confident: accuracy 0.875

The curve falls steadily as we answer more. Answering only the most confident half gives 0.984 accuracy; 80% gives 0.932; our service's point, about 90%, gives 0.899; answering everything gives 0.875. (The curve is jagged at the far left, where only a few reviews are answered.) This is the tool for a product conversation: "If people can review one review in ten, the model is right 0.899 of the time on the rest; if they can review half, 0.984."

7.4 Speed per request

Finally, how long does one request take? A request runs two models, the classifier and the review-likeness embedding.

Python 6 lines
one = [test_x[0]]service.predict(one)                                                  # warm-upt0 = benchmark_clock()for _ in range(10):    service.predict(one)print(f"one request: {(benchmark_clock() - t0) / 10 * 1000:.1f} ms (classification + review-likeness check)")
Output
one request: 34.3 ms (classification + review-likeness check)

Each request takes tens of milliseconds on this CPU (the printed time will differ on your machine). Lesson 7.6 looks at how latency, batching and hardware trade off in deployment.

How the Service Handled 1,000 Test Reviews. Of 1,000 source test reviews, 909 were answered, 79 received a confidence abstention and 12 were flagged out of domain. Held-back accuracy 0.637 applies to all 91 non-answered reviews.

Figure 7.5.2. Of 1,000 source test reviews, 909 were answered, 79 received a confidence abstention and 12 were flagged out of domain. Held-back accuracy 0.637 applies to all 91 non-answered reviews.

Key terms

  • Coverage: the share of inputs for which the system gives an answer.
  • Selective accuracy: accuracy measured only on the inputs the system answers.
  • Confusion matrix: a table counting each combination of true and predicted label.
  • Recall (per class): the share of a class's real examples that the model labels correctly.

Common mistake: reporting only the model's accuracy for an application with abstention or filtering. Always report coverage alongside accuracy on answered inputs. A system that answers only its easiest 10% can show near-perfect accuracy and still be useless.

Check your understandingCheck yourself
  1. A manager asks for "at least 0.95 accuracy". Using the coverage–accuracy numbers, what would you tell them, and what decision do they need to make?
Checkpoint Before you move on from §7

Why report coverage with answered-case accuracy?

Putting It All Together

Let's reassemble what we built.

Four pretrained models gave four capabilities with a few lines each. The sentiment model scored 0.882 on validation, handled negation that defeated Lesson 5.3's model, but gave near-certain answers to lukewarm and nonsensical inputs: a confidence of 0.996 for "asdkjh qwe zzz". The text generator wrote fluent but invented text. Greedy decoding looped (repeated 3-word sequences: 0.18 on average), while temperature, top-k and top-p sampling didn't repeat in any of their 9 texts. The sentence embeddings found reviews by meaning, but rated "A dull, badly acted film" as close to "A thrilling, beautifully acted film" (0.737) as a true paraphrase was (0.734): they capture topic, not opinion. CLIP classified Fashion-MNIST zero-shot with 0.733 accuracy, recognising sneakers that Lesson 5.5's toy could not, once we chose inversion and a template on held-out images. It still failed on shirts.

Capabilities become features through an application layer: input checks, an out-of-domain check (embeddings, with a threshold set so 99% of genuine validation reviews pass), and abstention (the least confident tenth, set on validation). On 1,000 unseen reviews, the service answered 909 with 0.899 accuracy, against 0.875 for answering everything; the 91 it held back (79 abstentions plus 12 out-of-domain flags) would have been right only 0.637 of the time. The confusion matrix showed which way the errors go (positive recall 0.856, negative 0.895), and the coverage–accuracy curve turns the threshold into a product decision.

Application Model What worked What failed
Sentiment DistilBERT (SST-2) 0.882 on validation; negation confident on lukewarm and nonsense inputs
Generation distilgpt2 fluent, varied text when sampling greedy loops; invented facts
Semantic search MiniLM finds reviews by meaning ranks by topic, not opinion
Zero-shot images CLIP 0.733 on Fashion-MNIST, sneakers 0.79 shirts 0.15
Sentiment service DistilBERT + MiniLM 0.899 accuracy at 0.909 coverage; OOD inputs flagged confident mistakes on mixed reviews remain
Four Capabilities, One Lesson. Each capability has failure modes. The notebook measures particular examples; similarity, confidence and fluent generation do not by themselves establish correctness.

Figure 7.5.3. Each capability has failure modes. The notebook measures particular examples; similarity, confidence and fluent generation do not by themselves establish correctness.

Interpret the evidence carefully. Here the 0.637 held-back accuracy applies to all 91 non-answered reviews, not just the 79 low-confidence abstentions. The semantic-similarity and repetition observations are results of these particular examples, models and settings, not universal properties of embeddings or sampling.

Summary

  • A pretrained model provides a capability; an application adds input checks, fit checks, decision rules and evaluation.
  • The sentiment model scored 0.882 on validation and handled negation correctly, but was near-certain on lukewarm reviews and on nonsense (0.996 confidence for "asdkjh qwe zzz"). Confidence does not mean an input made sense.
  • Temperature sharpens or flattens the next-token distribution. Greedy decoding looped and repeated itself; sampling with temperature, top-k or top-p produced varied text with no repeated 3-word sequences in any of 9 samples, though distilgpt2 invents facts.
  • Sentence embeddings (MiniLM, mean pooling) power semantic search, but capture topic rather than opinion: an opposite-sentiment sentence scored 0.737, a paraphrase 0.734.
  • CLIP reached 0.733 zero-shot accuracy on Fashion-MNIST, with inversion and template chosen on 300 separate images. Sneakers 0.79, shirts 0.15.
  • A reusable inference service validates inputs, never lets one bad input break a batch, flags out-of-domain text with an embedding-similarity check, abstains on low confidence, and returns a status and reason for every input.
  • On 1,000 test reviews: coverage 0.909, accuracy on answered reviews 0.899 (against 0.875 answering everything), and the held-back reviews would have been right only 0.637 of the time. Report coverage with accuracy, and use a confusion matrix to see which errors the model makes.

Glossary

Term Meaning
Capability What a model computes from an input.
Application layer The code around a model: input checks, fit checks, decision rules, error handling.
Out-of-distribution (OOD) input An input unlike the data a model was trained on.
Confidence The probability a classifier gives to its chosen class.
Temperature A divisor applied to logits before softmax, sharpening (below 1) or flattening (above 1) the distribution.
Greedy decoding Always choosing the most likely next token.
Top-k sampling Sampling from the k most likely tokens.
Top-p (nucleus) sampling Sampling from the smallest set of tokens with total probability at least p.
Degenerate repetition A generator getting stuck repeating words or tokens.
Sentence-embedding model A model mapping a text to one vector so similar meanings are close.
Mean pooling Averaging token vectors, ignoring padding, to get one vector per text.
Semantic search Retrieving documents by similarity of meaning.
Zero-shot classification Classifying into classes described in words, without training on them.
Prompt template A sentence pattern used to describe each class, such as "a photo of a {}.".
Text-to-image retrieval Finding images whose embeddings are closest to a text query's.
Inference service A wrapper that turns a model into a dependable component with structured results.
Abstention Declining to answer, usually because confidence is low.
Out-of-domain detection Checking whether an input resembles the data a model was built for.
Threshold The cut-off value in a decision rule, set on validation data.
Coverage The share of inputs the system answers.
Selective accuracy Accuracy on the answered inputs only.
Confusion matrix A table counting each combination of true and predicted label.
Recall The share of a class's real examples labelled correctly.

Wrap-up

Knowledge check

Quick check

1

What turns a model capability into a usable feature?

2

Does high semantic similarity guarantee matching sentiment?

3

Why report coverage with answered-case accuracy?

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.