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

Preparing Data for Fine-Tuning

Classroom material

44 min · 18 code cells · 1 labs

In this lesson
Time
~44 min
Sections
13
Code cells
18
Labs
1
Figures
3

Before you start

How do we turn raw data into a dataset that adaptation can learn from reliably, and that gives honest evaluation numbers?

You will be able to
  • define a task's inputs, outputs and labels, and read a dataset card critically;
  • run basic quality checks and find exact and near-duplicate examples;
  • format the same example for a classifier and for an instruction-tuned chat model;
  • build reproducible train/validation/test splits and explain how leakage creeps in;
  • explain why accuracy misleads on imbalanced data, and which metrics to use instead;
  • choose how to fit long texts into a fixed token budget, by measurement;
  • build and save a tokenised dataset with Hugging Face Datasets.

Lesson 8.1 showed fine-tuning learning a task from a very small set of reviews, and starting to overfit them almost immediately. When a model learns from so little, every example counts. Duplicated reviews count twice, mislabelled ones teach the wrong lesson, and a review that slips from the test set into training makes the final score a lie. A fine-tuned model can be no better than the data it was adapted on, and it can look much better than it is if that data is prepared carelessly.

This lesson prepares the data that Lessons 8.3–8.7 train and evaluate on. We'll go through the whole path from a raw download to a training-ready dataset: defining the task, checking quality, cleaning and removing duplicates, formatting examples (including for chat models), splitting without leakage, checking class balance, and fitting long texts into a model's limited input. Each step is measured on IMDb, so you can see what it actually changes. At the end we save the result in the format the training notes will use.

Key question: How do we turn raw data into a dataset that adaptation can learn from reliably, and that gives honest evaluation numbers?

Prerequisites

read Lesson 8.1. You'll use the fixed IMDb split (Lesson 7.1), Hugging Face Datasets (Lesson 7.2), tokenisation, padding and truncation (Lesson 7.4), and chat templates (Lesson 8.1). The data-split and leakage ideas from Module 4 come back in more depth here.

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

The usual imports, seeds and device check, and the same harmless warnings as in earlier lessons. One more message appears in Section 9: "Token indices sequence length is longer than the specified maximum…", printed when we count the tokens in full reviews 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.

Setup 36 lines
import os, re, hashlibfrom collections import Counterimport numpy as npimport torchimport matplotlib.pyplot as pltfrom scipy.stats import binomtestfrom datasets import load_dataset, Dataset, DatasetDictfrom huggingface_hub import DatasetCardfrom transformers import AutoTokenizer, AutoModelForSequenceClassification, DataCollatorWithPaddingtorch.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
Output
PyTorch 2.14.1 | device: cpu
1

The Big Picture: From Raw Data to a Training-Ready Dataset

Data preparation is a pipeline, and each stage answers a question:

  1. Task: what goes in, what comes out, and what does each label mean?
  2. Quality: is the data what we think it is? Empty texts, markup, oddities?
  3. Cleaning and deduplication: remove noise, and make sure each example appears once.
  4. Formatting: shape each example the way the model expects it, whether text plus label for a classifier or a conversation for a chat model.
  5. Splits: separate training, validation and test data, reproducibly.
  6. Leakage: make sure no evaluation example, or anything derived from it, reaches training.
  7. Balance: check how the labels are distributed, and choose metrics that suit it.
  8. Length: fit each example into the model's token limit without losing what matters.
  9. Packaging: tokenise and store the result in a form training code can stream.

The order matters. Several of the worst mistakes, such as splitting before deduplicating, come from doing the right steps in the wrong order.

The Data Preparation Pipeline. Group duplicate examples before splitting; keep validation and test out of training updates. Fit learned preprocessing on train, then apply the declared policy to held-out data.

Figure 8.2.1. Group duplicate examples before splitting; keep validation and test out of training updates. Fit learned preprocessing on train, then apply the declared policy to held-out data.

Checkpoint Before you move on from §1

What makes a dataset training-ready?

2

Defining the Task and Expected Output

Before touching the data, be precise about the task: one IMDb review in, one label out, positive or negative. The labels are the model's expected output, so we need to know exactly what they mean. The first place to look is the dataset card (Lesson 7.2):

Python 5 lines
card = DatasetCard.load("stanfordnlp/imdb")print("licence:", card.data.to_dict().get("license"), "| task:", card.data.to_dict().get("task_categories"))summary = card.text[card.text.index("### Dataset Summary"):card.text.index("### Supported Tasks")]print(summary.strip())print("\nsections that say 'More Information Needed':", card.text.count("More Information Needed"))
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
licence: ['other'] | task: ['text-classification']
### Dataset Summary

Large Movie Review Dataset.
This is a dataset for binary sentiment classification containing substantially more data than previous benchmark datasets. We provide a set of 25,000 highly polar movie reviews for training, and 25,000 for testing. There is additional unlabeled data for use as well.

sections that say 'More Information Needed': 16

The card tells us the task (binary sentiment classification), the size (25,000 training and 25,000 test reviews) and one crucial detail: the reviews are highly polar. The dataset's authors kept strongly positive and strongly negative reviews, judged from the reviewers' own star ratings, and left out the lukewarm middle. So a model trained on IMDb has never seen a genuinely mixed review labelled as such, which helps explain the lukewarm-review behaviour we saw in Lesson 7.5.

The card is also a warning about documentation. Its licence is other, and 16 places in it say "More Information Needed", including languages and how the labels were made. Many dataset cards are incomplete; when one is, find the original source (here the authors' website and paper) before relying on the data.

Now the data itself:

Python 6 lines
raw = 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"))print(raw)print("label names:", raw["train"].features["label"].names)for split in ("train", "test"):    print(f"{split}: reviews per label {np.bincount(list(raw[split]['label']))}")
Output
DatasetDict({
    train: Dataset({
        features: ['text', 'label'],
        num_rows: 25000
    })
    test: Dataset({
        features: ['text', 'label'],
        num_rows: 25000
    })
})
label names: ['neg', 'pos']
train: reviews per label [12500 12500]
test: reviews per label [12500 12500]

Two columns: text and label, with label names neg (0) and pos (1). That is our expected output: an integer class, 0 or 1.

Key terms

  • Expected output (target): what the model should produce for each input; here, a label 0 or 1.
  • Labelling rule: how the labels were decided; for IMDb, from the reviewers' star ratings, keeping only clearly positive or negative reviews.
  • Highly polar: strongly one-sided; IMDb excludes mixed or neutral reviews.
Checkpoint Before you move on from §2

What do IMDb's polar labels mean?

3

Data Collection and Quality

Data rarely arrives clean. A few cheap checks catch most surprises: lengths, empty entries, markup and stray content.

Python 8 lines
texts = list(raw["train"]["text"]) + list(raw["test"]["text"])labels = np.array(list(raw["train"]["label"]) + list(raw["test"]["label"]))words = np.array([len(t.split()) for t in texts])print(f"{len(texts)} reviews | words per review: shortest {words.min()}, median {np.median(words):.0f}, longest {words.max()}")print(f"empty or whitespace-only: {sum(not t.strip() for t in texts)}")tags = Counter(tag.lower() for t in texts for tag in re.findall(r"<[^>]+>", t))print(f"reviews containing HTML tags: {np.mean([bool(re.search(r'<[^>]+>', t)) for t in texts]):.1%} | most common tags: {tags.most_common(3)}")print(f"reviews containing a web address: {sum(bool(re.search(r'https?://|www\\.', t)) for t in texts)}")
Output
50000 reviews | words per review: shortest 4, median 173, longest 2470
empty or whitespace-only: 0
reviews containing HTML tags: 58.4% | most common tags: [('<br />', 201935), ('<i>', 8), ('</i>', 8)]
reviews containing a web address: 119

Over all 50000 reviews:

  • Lengths vary enormously, from 4 words to 2470, with a median of 173. Length is the subject of Section 9.
  • No review is empty.
  • 58.4% contain HTML tags. Almost all are <br /> line breaks (201935 of them), plus a handful of italics tags. These are left over from the web pages the reviews were collected from, and they mean nothing to a sentiment model.
  • 119 reviews contain a web address.

The shortest reviews are worth a look, because extremes often reveal problems:

Python 2 lines
for i in np.argsort(words)[:3]:                                     # the three shortest reviews    print(f"{'pos' if labels[i] else 'neg'} | {words[i]} words | {texts[i]}")
Output
neg | 4 words | Primary plot!Primary direction!Poor interpretation.
neg | 6 words | Read the book, forget the movie!
neg | 8 words | More suspenseful, more subtle, much, much more disturbing....

They are short but genuine. "Read the book, forget the movie!" is a perfectly clear negative review. The third shows something else: "More suspenseful, more subtle, much, much more disturbing...." is labelled negative, yet on its own it reads as praise. It's probably comparing the film unfavourably with something else. Some labels can only be understood with context the text doesn't contain, so a perfect score is not a realistic target.

Common mistake: skipping a manual look at the data. Summary statistics can't tell you that the shortest "reviews" are real, or that a label needs context. Reading a few examples, chosen by a rule such as the shortest or the first ones, takes minutes and often changes your plan.

Checkpoint Before you move on from §3

Why inspect raw examples as well as counts?

4

Cleaning and Deduplication

Cleaning removes content that carries no signal for the task. For IMDb that means the <br /> tags, which we replace with spaces (the italics tags are too rare to matter). We'll keep the web addresses: they're rare, and a reviewer linking to something is part of what they wrote.

Deduplication matters more. A review that appears twice is weighted twice in training, and if its copies end up on both sides of a split, the model is tested on something it has already seen. We look for exact copies, and for near-copies that differ only in capitalisation, punctuation or spacing:

Python 11 lines
tr, te = list(raw["train"]["text"]), list(raw["test"]["text"])def normalise(t):    """Normalise case, punctuation and spacing for a duplicate audit; this is separate from the exact-dedup split policy.    Arguments:        t: One text from the duplicate audit.    """    return re.sub(r"[^a-z0-9]+", " ", t.replace("<br />", " ").lower()).strip()   # case, punctuation, spacingfor name, xs in (("train", tr), ("test", te)):    print(f"{name}: exact duplicate rows {len(xs) - len(set(xs))} | after normalising {len(xs) - len(set(map(normalise, xs)))}")print(f"reviews in both train and test: exact {len(set(tr) & set(te))} | after normalising {len(set(map(normalise, tr)) & set(map(normalise, te)))}")
Output
train: exact duplicate rows 96 | after normalising 99
test: exact duplicate rows 199 | after normalising 202
reviews in both train and test: exact 123 | after normalising 123

Each official half contains exact duplicates: 96 extra rows in train and 199 in test. Normalising text adds only 3 more near-copies in each half (99 and 202), so IMDb's duplicates are mostly exact. Most importantly, 123 reviews appear in both official halves, so evaluating on the official test set without deduplication would test the model on 123 reviews it trained on.

Do duplicates at least agree on their labels?

Python 7 lines
by_text = {}for t, y in zip(texts, labels):    by_text.setdefault(normalise(t), set()).add(int(y))print("duplicated texts with conflicting labels:", sum(len(v) > 1 for v in by_text.values()))counts = Counter(tr)dup_text, n = counts.most_common(1)[0]print(f"most repeated training review appears {n} times: {dup_text[:150]}...")
Output
duplicated texts with conflicting labels: 0
most repeated training review appears 3 times: This show comes up with interesting locations as fast as the travel channel. It is billed as reality but in actuality it is pure prime time soap opera...

Yes: no duplicated text carries conflicting labels, so there's no label noise to resolve here. If copies of a text disagreed, we'd have to decide which label is right, or drop them. The most repeated training review appears 3 times. It reads like a description of a TV show, a reminder that "movie reviews" scraped from the web aren't always about movies.

Key terms

  • Cleaning: removing content that carries no signal for the task, such as leftover HTML markup.
  • Exact duplicate: an example identical to another, character for character.
  • Near-duplicate: an example that differs from another only trivially (here: case, punctuation, spacing).
  • Normalisation: transforming text into a standard form so that trivially different texts compare as equal.
Check your understandingCheck yourself
  1. Normalising case and punctuation found only 3 more duplicates per half. Name another kind of near-duplicate that our normalisation would miss, and how you might find it.

Visual walkthrough: Keep duplicate groups in one split.

Keep duplicate groups in one split; exact steps are described below.

1. Find identical examples. A: “good film” · B: “bad film” · C: “good film”. This exact-match toy policy groups A and C; real preprocessing and near-duplicate policies must be declared.

2. Group before splitting. Group 1: {A, C} · Group 2: {B}. Splitting rows first could place the same text in train and test and inflate evaluation.

3. Assign whole groups. Train: {A, C} · Test: {B}. This two-group example explains the boundary; it is far too small for a useful model evaluation.

4. Fit only on train. Learn preprocessing from train → apply to test. Test data never supplies gradients or fitted vocabulary/statistics under this protocol. Validation is a separate group-held-out split.

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

Interactive

Keep duplicate groups in one split

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

Loading interactive…

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

Checkpoint Before you move on from §4

When should duplicate groups be handled?

5

Labelling and Instruction Formatting

The same data can be shaped for different kinds of model. For a classifier, as in Lessons 8.3–8.5, an example is just the text and an integer label. For an instruction-tuned chat model, as in Lesson 8.6, the same example becomes a short conversation: an instruction containing the review, and the answer written out as words, wrapped in the model's chat template (Lesson 8.1):

Python 7 lines
review, label = tr[0].replace("<br />", " "), raw["train"][0]["label"]as_classification = {"text": review[:120] + "...", "label": label}print("classification example:", as_classification)chat_tok = AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-135M-Instruct", revision=MODEL_REVISIONS.get("HuggingFaceTB/SmolLM2-135M-Instruct"))       # tokeniser files onlymessages = [{"role": "user", "content": f"Review: {review[:120]}...\nIs this movie review positive or negative?"},            {"role": "assistant", "content": raw["train"].features["label"].names[label].replace("neg", "negative").replace("pos", "positive")}]print("\ninstruction example:\n" + chat_tok.apply_chat_template(messages, tokenize=False))
Output
classification example: {'text': 'I rented I AM CURIOUS-YELLOW from my video store because of all the controversy that surrounded it when it was first rel...', 'label': 0}

instruction example:
<|im_start|>system
You are a helpful AI assistant named SmolLM, trained by Hugging Face<|im_end|>
<|im_start|>user
Review: I rented I AM CURIOUS-YELLOW from my video store because of all the controversy that surrounded it when it was first rel...
Is this movie review positive or negative?<|im_end|>
<|im_start|>assistant
negative<|im_end|>

The classification example is {'text': ..., 'label': 0}. The instruction example is the same review and label, rewritten as a user turn ("Is this movie review positive or negative?") and an assistant turn ("negative"), inside the special markers SmolLM2 was trained with. Only the tokeniser was downloaded for this, a few megabytes.

Instruction formatting turns any labelled dataset into training data for a chat model. The choices you make (the wording of the instruction, the exact answer words, the system message) become part of what the model learns, so keep them consistent across every example. Lesson 8.6 shows how such examples are trained on, including the important detail of which tokens the loss is computed on.

Key terms

  • Instruction formatting: rewriting a labelled example as an instruction and a response for a chat model.
  • User / assistant turn: the parts of a conversation written by the user and by the model.
Check your understandingCheck yourself
  1. Half your instruction examples end with the answer "negative" and half with "Negative." (capitalised, with a full stop). What problem might this cause after fine-tuning?
Checkpoint Before you move on from §5

How does classifier formatting differ from instruction formatting?

6

Training, Validation and Test Splits

Module 4 introduced the three splits. Training data fits the weights, validation data chooses settings and checkpoints, and test data is used once at the end to estimate real-world performance. Here is the function that builds the split used throughout Modules 7 and 8, with the steps in the right order: deduplicate each half, drop test reviews that also appear in train, clean, then carve validation out of the training half with a fixed seed.

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

The result has 22,404 training reviews, 2,500 validation reviews and 24,678 test reviews, each close to half positive. val_eval and test_eval are the fixed 1,000-review samples used for most evaluations. The fingerprints are the split's identity card: any notebook that prints the same fingerprints is evaluating on exactly the same reviews, so results can be compared fairly.

Why fix the seed? Change it, and you get a different split:

Python 3 lines
other = load_imdb_split(seed=1, verbose=False)print("same function, seed 1: val fingerprint", fingerprint(other["val"][0]), "| reviews shared with seed 0's val:",      len(set(other["val"][0]) & set(imdb["val"][0])), "of", len(imdb["val"][0]))
Output
same function, seed 1: val fingerprint 6f3900fc35f2 | reviews shared with seed 0's val: 226 of 2500

With seed 1, the validation set's fingerprint changes, and only 226 of its 2,500 reviews were in seed 0's validation set, about what chance predicts. Results measured on the two splits are not directly comparable. Fixing the seed, and printing a fingerprint to prove it, is what lets Lessons 8.3–8.7 compare their numbers.

Key terms

  • Training / validation / test split: data for fitting weights / for choosing settings / for one final estimate.
  • Fingerprint: a short hash of a split's contents that proves which examples it contains.
  • Reproducible split: a split that comes out the same every time, by fixing the random seed.
Checkpoint Before you move on from §6

What should remain consistent across model comparisons?

7

Leakage and Contamination, Including Pretraining Contamination

Leakage is any way information from evaluation data reaches training. It makes scores optimistic, sometimes wildly so. The most common cause is doing steps in the wrong order. Here's what would have happened if we had split IMDb's training half before removing duplicates:

Python 7 lines
rng = np.random.default_rng(0)p = rng.permutation(len(tr))                                          # split the RAW training half, duplicates and allnaive_val, naive_train = [tr[i] for i in p[:2500]], set(tr[i] for i in p[2500:])print("split BEFORE deduplication: validation reviews with a copy in training:", sum(x in naive_train for x in naive_val))tr_set = set(imdb["train"][0])print("our split (deduplicated first): validation reviews in training:", sum(x in tr_set for x in imdb["val"][0]),      "| test reviews in training:", sum(x in tr_set for x in imdb["test"][0]))
Output
split BEFORE deduplication: validation reviews with a copy in training: 16
our split (deduplicated first): validation reviews in training: 0 | test reviews in training: 0

Splitting first leaves 16 validation reviews with an identical copy in the training part. The model would be "validated" partly on reviews it had memorised. Deduplicating first, as our function does, leaves none, and no test review appears in training either.

Leakage takes other forms too:

  • Leakage through preprocessing. Anything fitted on data, such as a vocabulary, a scaler or a choice of settings, must be fitted on training data only. Choosing a setting by its test score is leakage too.
  • Leakage through groups. Several reviews by the same person, or about the same film, can share phrasing. If one goes to training and another to test, the test is easier than real use. When groups matter, split by group.
  • Contamination of a pretrained model. In Lesson 7.7 we found a model fine-tuned on IMDb's training half, which contains our validation set, and that had chosen its checkpoint on IMDb's test half. Its validation score measured memory. Pretraining can contaminate too: a model pretrained on a large web crawl may have read IMDb reviews, or even the benchmark itself. DistilBERT was pretrained on books and Wikipedia (Lesson 7.3), which makes this unlikely for our model, but for models trained on web crawls you often can't rule it out. The safest defence is evaluation data the model can't have seen, such as data collected after its training.

Common mistake: splitting first and cleaning later. Deduplication, filtering and any fitted preprocessing must happen before the split (for deduplication) or on the training split only (for anything learned from data). Otherwise evaluation data leaks into training.

Check your understandingCheck yourself
  1. A team picks the epoch with the best test accuracy and reports that accuracy. Which kind of leakage is this, and why does it make the number optimistic?
Checkpoint Before you move on from §7

Can a public checkpoint contaminate your evaluation?

8

Class Imbalance and Data Distribution

IMDb is exactly balanced (the official halves each have 12,500 reviews of each class), and our splits stay close to half positive. Real data often isn't balanced: complaints might be 5% of support tickets, fraud far less of transactions. Let's see what imbalance does to evaluation.

We need a real classifier's predictions, so we load the SST-2 model from Module 7 and run it on 500 validation reviews. The encode function fits each review into 128 tokens in one of three ways, which Section 9 explains and compares. For now we use the version labelled head+tail.

Python 43 lines
SST2 = "distilbert/distilbert-base-uncased-finetuned-sst-2-english"tok = AutoTokenizer.from_pretrained(SST2, revision=MODEL_REVISIONS.get(SST2))clf = AutoModelForSequenceClassification.from_pretrained(SST2, revision=MODEL_REVISIONS.get(SST2)).to(device).eval()def encode(texts, max_length=128, strategy="head+tail"):    """Keep the requested head, tail or head-and-tail tokens, add special tokens and pad the batch.    Arguments:        texts: Text strings in the order expected by the caller.        max_length: Token budget including special tokens.        strategy: Explicit head, tail or head-and-tail truncation policy.    """    keep = max_length - 2                                         # room for [CLS] and [SEP]    rows = []    for ids in tok(texts, add_special_tokens=False)["input_ids"]:        if len(ids) > keep:            if strategy == "head":                ids = ids[:keep]            elif strategy == "tail":                ids = ids[-keep:]            else:                ids = ids[:keep // 4] + ids[-(keep - keep // 4):]   # first quarter + last three quarters        rows.append([tok.cls_token_id] + ids + [tok.sep_token_id])    n = max(len(r) for r in rows)    input_ids = torch.tensor([r + [tok.pad_token_id] * (n - len(r)) for r in rows])    return {"input_ids": input_ids, "attention_mask": (input_ids != tok.pad_token_id).long()}def predict(texts, **kw):    """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.        **kw: Explicit settings forwarded to the operation described above.    """    out = []    with torch.inference_mode():        for i in range(0, len(texts), 32):            enc = {k: v.to(device) for k, v in encode(texts[i:i + 32], **kw).items()}            out.append(clf(**enc).logits.argmax(-1).cpu())    return torch.cat(out).numpy()val_x, val_y = imdb["val_eval"][0][:500], imdb["val_eval"][1][:500]   # the first 500 validation reviewspred = {s: predict(val_x, strategy=s) for s in ("head", "tail", "head+tail")}
Warnings / log
W1017 19:31:12.688000 86260 site-packages/torch/distributed/elastic/multiprocessing/redirects.py:35] NOTE: Redirects are currently not supported in MacOs.
W1017 19:31:12.791000 86260 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 19:31:12.814000 86260 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.
Loading weights:   0%|          | 0/104 [00:00<?, ?it/s]Loading weights: 100%|██████████| 104/104 [00:00<00:00, 12200.93it/s]
[transformers] Token indices sequence length is longer than the specified maximum sequence length for this model (754 > 512). Running this sequence through the model will result in indexing errors

Now we build an imbalanced evaluation set by a fixed rule: the first 225 negative and the first 25 positive reviews from those 500, so 90% negative. We compare the SST-2 model with a "model" that always says negative:

Python 19 lines
neg_idx, pos_idx = np.where(val_y == 0)[0], np.where(val_y == 1)[0]skewed = np.concatenate([neg_idx[:225], pos_idx[:25]])              # 90% negative, by rule: the first of eachy_s, p_s = val_y[skewed], pred["head+tail"][skewed]always_neg = np.zeros_like(y_s)def report(name, y, p):    """Print accuracy, per-class recall and positive-class F1 for the declared predictions.    Arguments:        name: Display label for the printed metric row.        y: Targets aligned with the examples.        p: Predicted binary classes.    """    rec = [((p == c) & (y == c)).sum() / (y == c).sum() for c in (0, 1)]    tp, fp, fn = ((p == 1) & (y == 1)).sum(), ((p == 1) & (y == 0)).sum(), ((p == 0) & (y == 1)).sum()    f1 = 2 * tp / max(2 * tp + fp + fn, 1)    print(f"{name:<22} accuracy {(p == y).mean():.3f} | recall negative {rec[0]:.3f} | recall positive {rec[1]:.3f} | F1 (positive) {f1:.3f}")print(f"imbalanced set: {len(y_s)} reviews, {1 - y_s.mean():.0%} negative")report("always 'negative'", y_s, always_neg)report("SST-2 model", y_s, p_s)
Output
imbalanced set: 250 reviews, 90% negative
always 'negative'      accuracy 0.900 | recall negative 1.000 | recall positive 0.000 | F1 (positive) 0.000
SST-2 model            accuracy 0.848 | recall negative 0.853 | recall positive 0.800 | F1 (positive) 0.513

The do-nothing classifier wins on accuracy, 0.900 against 0.848, because it is right about every negative review, and negatives are 90% of the set. It's also useless: its recall on positive reviews is 0.000 and its F1 score for the positive class is 0.000. The real model finds 0.800 of the positive reviews and 0.853 of the negative ones. On imbalanced data, accuracy rewards ignoring the rare class, so report per-class recall, the F1 score for the class you care about, and the confusion matrix (Lesson 7.5), and always compare with the majority-class baseline.

Imbalance in training data causes a related problem: a model can learn to favour the common class. Common remedies are to resample (show the rare class more often), to weight the loss towards the rare class, or to move the decision threshold, each chosen on validation. IMDb needs none of these, but your data might.

Key terms

  • Class imbalance: some labels being much more common than others.
  • Majority-class baseline: always predicting the most common label; any useful model must beat it on the metrics that matter.
  • Recall: the share of a class's real examples that the model identifies.
  • F1 score: a single score combining precision (how many predicted positives are right) and recall; useful when one class is rare.
Check your understandingCheck yourself
  1. A fraud model reports 0.995 accuracy on data where 0.5% of transactions are fraud. What's the first number you'd ask for, and why?
Checkpoint Before you move on from §8

Why is accuracy insufficient on imbalanced data?

9

Tokenisation and Sequence-Length Management

Our model reads at most 512 tokens, and in Module 8 we'll train with only 128 to keep training affordable on a CPU. How much of a typical review fits?

Python 4 lines
n_tok = np.array([len(ids) for ids in tok(imdb["val_eval"][0], add_special_tokens=True)["input_ids"]])print(f"tokens per review (1,000 validation reviews): median {np.median(n_tok):.0f} | 90th percentile {np.percentile(n_tok, 90):.0f}")for L in (128, 256, 512):    print(f"  longer than {L} tokens: {(n_tok > L).mean():.1%}")
Output
tokens per review (1,000 validation reviews): median 214 | 90th percentile 608
  longer than 128 tokens: 85.6%
  longer than 256 tokens: 39.9%
  longer than 512 tokens: 13.9%

The median validation review is 214 tokens, and one in ten is longer than 608. At 128 tokens, 85.6% of reviews don't fit; even at 512, 13.9% don't. So which 128 tokens we keep is a real decision. There are three simple strategies:

  • head: keep the beginning, the usual default (Lesson 7.4);
  • tail: keep the end;
  • head+tail: keep the first quarter of the budget and the last three quarters, skipping the middle.

Here's what each keeps of one 215-token review:

Python 6 lines
sample = imdb["val_eval"][0][3]for s in ("head", "tail", "head+tail"):    ids = encode([sample], strategy=s)["input_ids"][0]    words_kept = tok.decode(ids[1:-1])    print(f"{s:<10} kept {len(ids)} tokens | starts: {words_kept[:60]!r} | ends: {words_kept[-60:]!r}")print(f"the review has {len(tok(sample)['input_ids'])} tokens in total")
Output
head       kept 128 tokens | starts: 'make the world go away. get it off my shoulder. say the thin' | ends: 'st what would you do if you were all alone in the world? the'
tail       kept 128 tokens | starts: 'nothing. the whole world, except for their house went away. ' | ends: 'atali, with help from the two stars. it just has to be seen.'
head+tail  kept 128 tokens | starts: 'make the world go away. get it off my shoulder. say the thin' | ends: 'atali, with help from the two stars. it just has to be seen.'
the review has 215 tokens in total

Head keeps the opening and stops mid-review. Tail and head+tail both keep the final verdict ("it just has to be seen."). Head+tail also keeps the opening. Which matters more is a question for measurement. Here are all three strategies with the SST-2 model at 128 tokens, on the same 500 validation reviews, with sign tests on paired predictions:

Python 6 lines
for s, p in pred.items():    print(f"{s:<10} 128 tokens: accuracy {(p == val_y).mean():.3f} on {len(val_y)} validation reviews")right = {s: p == val_y for s, p in pred.items()}for x_, y_ in (("head+tail", "head"), ("tail", "head"), ("tail", "head+tail")):    a = int((right[x_] & ~right[y_]).sum()); b = int((~right[x_] & right[y_]).sum())    print(f"{x_:<9} vs {y_:<9}: only {x_} right {a:>2} | only {y_} right {b:>2} | sign test p = {(binomtest(a, a + b).pvalue if a + b else 1.0):.2g}")
Output
head       128 tokens: accuracy 0.842 on 500 validation reviews
tail       128 tokens: accuracy 0.888 on 500 validation reviews
head+tail  128 tokens: accuracy 0.880 on 500 validation reviews
head+tail vs head     : only head+tail right 49 | only head right 30 | sign test p = 0.042
tail      vs head     : only tail right 52 | only head right 29 | sign test p = 0.014
tail      vs head+tail: only tail right 19 | only head+tail right 15 | sign test p = 0.61

Keeping the end of the review matters a great deal. Head scores 0.842; tail 0.888 and head+tail 0.880. Both beat head-only clearly (sign tests p = 0.014 and 0.042). Reviewers often summarise their verdict at the end, and the head-only cut throws it away. Between tail and head+tail, though, there's no meaningful difference: 19 reviews favour tail, 15 favour head+tail, p = 0.61.

We'll use head+tail for the rest of Module 8. It is statistically not significantly different in this paired comparison from tail on validation, and it keeps the opening as well, which helps reviews that state their verdict first. Either would be a defensible choice. Head-only would not.

Key terms

  • Token budget: the maximum number of tokens an example may use; here 128.
  • Head, tail and head+tail truncation: keeping the beginning, the end, or both ends of a text that exceeds the budget.

Common mistake: accepting the default truncation without checking it. Keeping only the beginning is the default almost everywhere, and for IMDb at 128 tokens it cost about four points of accuracy against keeping the end. Measure the options on validation.

Check your understandingCheck yourself
  1. For a dataset of customer emails, where the request usually comes in the first lines and the rest is a long signature and quoted history, which truncation would you try first, and how would you confirm it?
Checkpoint Before you move on from §9

How should a truncation strategy be chosen?

10

Practice: Building the Dataset with Hugging Face Datasets

Finally, we package everything for training. We take the 1,000 training reviews that Lessons 8.3–8.5 train on, the 1,000 val_eval reviews and the 1,000 test_eval reviews, apply head+tail truncation to 128 tokens, and store token IDs and labels in a DatasetDict (Lesson 7.2). We don't pad here: a data collator pads each batch to its own longest member when training (Lesson 7.4).

Python 19 lines
def to_dataset(x, y):    """Construct unpadded token-ID rows and integer labels for later dynamic batching.    Arguments:        x: Input features or token IDs with the shape traced at the call site.        y: Targets aligned with the examples.    """    enc = encode(x)                                                   # head+tail, 128 tokens    mask = enc["attention_mask"].bool()    return Dataset.from_dict({"input_ids": [ids[m].tolist() for ids, m in zip(enc["input_ids"], mask)],                              "labels": [int(v) for v in y]})        # unpadded: the collator pads each batchprepared = DatasetDict({    "train": to_dataset(imdb["train"][0][:1000], imdb["train"][1][:1000]),   # the 1,000 training reviews of Module 8    "validation": to_dataset(*imdb["val_eval"]),    "test": to_dataset(*imdb["test_eval"]),})print(prepared)print("longest example:", max(len(r) for r in prepared["train"]["input_ids"]), "tokens")
Output
DatasetDict({
    train: Dataset({
        features: ['input_ids', 'labels'],
        num_rows: 1000
    })
    validation: Dataset({
        features: ['input_ids', 'labels'],
        num_rows: 1000
    })
    test: Dataset({
        features: ['input_ids', 'labels'],
        num_rows: 1000
    })
})
longest example: 128 tokens

Three splits of 1,000 examples, each with input_ids and labels, and no example longer than 128 tokens. A data collator turns any 8 examples into a padded batch, adding the attention mask:

Python 6 lines
collate = DataCollatorWithPadding(tok)batch = collate([prepared["train"][i] for i in range(8)])print({k: tuple(v.shape) for k, v in batch.items()})prepared.save_to_disk("data/imdb-prepared")size = sum(os.path.getsize(os.path.join(d, f)) for d, _, fs in os.walk("data/imdb-prepared") for f in fs)print(f"saved to data/imdb-prepared: {size / 1e6:.1f} MB")
Output
{'input_ids': (8, 128), 'labels': (8,), 'attention_mask': (8, 128)}
Warnings / log
Saving the dataset (0/1 shards):   0%|          | 0/1000 [00:00<?, ? examples/s]Saving the dataset (1/1 shards): 100%|██████████| 1000/1000 [00:00<00:00, 652403.80 examples/s]Saving the dataset (1/1 shards): 100%|██████████| 1000/1000 [00:00<00:00, 576061.53 examples/s]Saving the dataset (0/1 shards):   0%|          | 0/1000 [00:00<?, ? examples/s]Saving the dataset (1/1 shards): 100%|██████████| 1000/1000 [00:00<00:00, 791676.86 examples/s]Saving the dataset (1/1 shards): 100%|██████████| 1000/1000 [00:00<00:00, 697771.42 examples/s]Saving the dataset (0/1 shards):   0%|          | 0/1000 [00:00<?, ? examples/s]Saving the dataset (1/1 shards): 100%|██████████| 1000/1000 [00:00<00:00, 963322.00 examples/s]Saving the dataset (1/1 shards): 100%|██████████| 1000/1000 [00:00<00:00, 831873.07 examples/s]
Output
saved to data/imdb-prepared: 1.5 MB

The batch has the shapes training code expects: input_ids and attention_mask of shape (8, 128), and 8 labels. Saved with save_to_disk, the whole prepared dataset takes 1.5 MB, in Arrow format. Lessons 8.3–8.5 rebuild exactly the same data with the same functions, so that each notebook stands alone; the fingerprints confirm they match.

Key terms

  • DatasetDict: a dictionary of named Dataset splits (Lesson 7.2).
  • Data collator: a function that combines examples into a padded batch.
  • save_to_disk: saves a dataset in Arrow format for fast reloading.
Checkpoint Before you move on from §10

What does a padding collator do?

Putting It All Together

Let's reassemble the pipeline, with what each step found on IMDb.

We defined the task (one review in, a 0/1 label out) and learned from the dataset card that IMDb uses polar rating labels (negative ratings at most 4/10, positive ratings at least 7/10); the prose can still contain mixed opinions, though the card itself is incomplete: 16 sections say "More Information Needed". Quality checks on 50000 reviews found no empty texts but 58.4% containing leftover HTML, mostly <br /> line breaks, which we cleaned. Deduplication found 96 duplicate rows in train, 199 in test and 123 reviews in both halves, with only 3 more near-copies per half after normalising, and no conflicting labels. The same review can be formatted for a classifier or, through a chat template, for an instruction model.

The split deduplicates first, then carves validation with a fixed seed, and fingerprints prove which reviews each notebook uses. A different seed gives a different split, sharing only 226 of 2,500 validation reviews. Doing the steps in the wrong order would have leaked 16 validation reviews into training. On an artificially imbalanced set, a do-nothing classifier scored 0.900 accuracy against the real model's 0.848, while finding none of the positive reviews: use recall and F1. Finally, length management mattered most of all: at 128 tokens, keeping the end of each review raised accuracy from 0.842 (head) to 0.880 (head+tail) or 0.888 (tail), and we chose head+tail. The prepared dataset is saved for Lessons 8.3–8.5.

Step Finding on IMDb Decision
Task and labels binary, highly polar; incomplete card 0 = negative, 1 = positive
Quality 58.4% contain HTML; no empty reviews replace <br /> with spaces
Deduplication 96 / 199 duplicate rows; 123 across halves deduplicate before splitting
Splits seed 1 shares 226 of 2,500 val reviews with seed 0 fixed seed 0, fingerprints printed
Leakage splitting first leaks 16 validation reviews dedup, then split
Balance 0.900 for "always negative" on a 90/10 set report recall and F1
Length head 0.842, head+tail 0.880, tail 0.888 at 128 tokens head+tail, 128 tokens
What Each Preparation Step Changed. Counts and token-budget comparisons from the supplied IMDb audit. Exact deduplication defines this course split; the separate normalised audit is not silently substituted for it.

Figure 8.2.2. Counts and token-budget comparisons from the supplied IMDb audit. Exact deduplication defines this course split; the separate normalised audit is not silently substituted for it.

Summary

  • Define the task and labels first, and read the dataset card critically. IMDb uses polar rating labels (negative ratings at most 4/10, positive ratings at least 7/10); the prose can still contain mixed opinions, and its card is incomplete (16 × "More Information Needed").
  • Check quality with simple statistics and a manual look: lengths from 4 to 2470 words, no empty reviews, 58.4% with leftover HTML (<br />), 119 with web addresses.
  • Deduplicate before splitting. IMDb has 96 duplicate rows in train, 199 in test and 123 reviews in both halves; normalising adds only 3 near-copies per half; no duplicates conflict on labels.
  • The same example can be formatted for a classifier (text + label) or for a chat model (instruction + answer in a chat template).
  • Splits must be reproducible: a fixed seed and fingerprints. A different seed shared only 226 of 2,500 validation reviews.
  • Leakage: splitting before deduplication would have put 16 validation reviews into training. Fit preprocessing on training data only, split by group when needed, and beware contaminated pretrained models.
  • Imbalance: on a 90/10 set, "always negative" scored 0.900 accuracy and 0.000 F1 for positives; the real model scored 0.848 but found 0.800 of the positives. Report recall and F1.
  • Length: at 128 tokens, 85.6% of reviews don't fit. Keeping the end matters: head 0.842, head+tail 0.880, tail 0.888 (tail vs head+tail not significant, p = 0.61). We use head+tail.
  • The prepared DatasetDict (1,000 train, 1,000 validation, 1,000 test, head+tail 128 tokens) is saved for the training notes.

Glossary

Term Meaning
Expected output (target) What the model should produce for each input.
Labelling rule How labels were decided.
Highly polar Labels derived from extreme ratings; review prose may still contain mixed opinions.
Dataset card The documentation page of a dataset on the Hub.
Cleaning Removing content with no signal for the task.
Exact / near-duplicate Identical examples / examples differing only trivially.
Normalisation Converting text to a standard form so trivial differences disappear.
Instruction formatting Rewriting a labelled example as an instruction and response for a chat model.
Train / validation / test split Data for fitting / for choosing settings / for one final estimate.
Fingerprint A short hash proving which examples a split contains.
Leakage Evaluation information reaching training, inflating scores.
Contamination Evaluation data appearing in a pretrained model's training data.
Group split Splitting so that related examples (same author, same item) stay on one side.
Class imbalance Some labels being much more common than others.
Majority-class baseline Always predicting the most common label.
Recall The share of a class's real examples the model identifies.
Precision The share of the model's predictions for a class that are correct.
F1 score A combined score of precision and recall for one class.
Token budget The maximum number of tokens per example.
Head / tail / head+tail truncation Keeping the start, the end, or both ends of a long text.
Data collator A function that pads examples into a batch.
save_to_disk Saves a Hugging Face dataset in Arrow format.

Wrap-up

Knowledge check

Quick check

1

What makes a dataset training-ready?

2

What should remain consistent across model comparisons?

3

What does a padding collator do?

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.