Preparing Data for Fine-Tuning
Classroom material
- 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.
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()
/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
PyTorch 2.14.1 | device: cpu
The Big Picture: From Raw Data to a Training-Ready Dataset
Data preparation is a pipeline, and each stage answers a question:
- Task: what goes in, what comes out, and what does each label mean?
- Quality: is the data what we think it is? Empty texts, markup, oddities?
- Cleaning and deduplication: remove noise, and make sure each example appears once.
- Formatting: shape each example the way the model expects it, whether text plus label for a classifier or a conversation for a chat model.
- Splits: separate training, validation and test data, reproducibly.
- Leakage: make sure no evaluation example, or anything derived from it, reaches training.
- Balance: check how the labels are distributed, and choose metrics that suit it.
- Length: fit each example into the model's token limit without losing what matters.
- 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.

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.
What makes a dataset training-ready?
An explicit task, checked examples, honest splits and compatible preprocessing.
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):
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"))
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
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:
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']))}")
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.
What do IMDb's polar labels mean?
Selection by low/high ratings; individual texts may mix opinions.
Data Collection and Quality
Data rarely arrives clean. A few cheap checks catch most surprises: lengths, empty entries, markup and stray content.
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)}")
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:
for i in np.argsort(words)[:3]: # the three shortest reviews print(f"{'pos' if labels[i] else 'neg'} | {words[i]} words | {texts[i]}")
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.
Why inspect raw examples as well as counts?
Aggregates can miss labelling or content problems.
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:
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)))}")
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?
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]}...")
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.
-
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.
For example: the same review with a few words changed or a sentence added, or the same text posted with a different ending. Normalisation only removes trivial formatting differences. To catch these you'd compare overlapping word sequences (for example with a technique called MinHash), or compare sentence embeddings (Lesson 7.5) and flag pairs with very high similarity for review.
Visual walkthrough: Keep duplicate groups in one split.

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.
Paused on arrival. Use Previous step, Play/Pause, Next step or Reset. No model runs in this walkthrough.
When should duplicate groups be handled?
Before splitting, with cross-split overlap checks.
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):
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))
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.
-
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?
The model learns two answer forms, so at prediction time it may produce either, and code that looks for exactly "negative" will miss "Negative.". The examples also teach the model that the answer's form varies for no reason, which wastes some of what it learns. Choose one format and apply it to every example.
How does classifier formatting differ from instruction formatting?
A class label versus a conversation with an answer target.
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.
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]
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)}")
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:
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]))
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.
What should remain consistent across model comparisons?
The declared data protocol and ordered evaluation examples.
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:
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]))
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.
-
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?
It's leakage through model selection: the test set has been used to make a choice. Among several epochs, the one with the best test score is partly the luckiest on that test set, so its reported score includes that luck and overstates performance on new data. Choose the epoch on validation, then report test once.
Can a public checkpoint contaminate your evaluation?
Yes; upstream training and selection history can expose it.
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.
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")}
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:
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)
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.
-
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?
The recall on fraud (and the precision, or the F1 score for the fraud class). A model that labels every transaction "not fraud" already scores 0.995 accuracy, so accuracy says nothing about whether the model catches any fraud at all. Compare with that majority-class baseline too.
Why is accuracy insufficient on imbalanced data?
A majority-only predictor may ignore the class you need.
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?
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%}")
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:
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")
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:
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}")
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.
-
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?
Head truncation, because the request comes first and the tail is signature and quoted history. Confirm it as in Section 9: run the options (head, tail, head+tail) on the same validation emails, compare accuracy with a sign test, and choose on validation, not on intuition alone.
How should a truncation strategy be chosen?
On validation with its information loss and compute cost stated.
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).
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")
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:
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")
{'input_ids': (8, 128), 'labels': (8,), 'attention_mask': (8, 128)}
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]
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 namedDatasetsplits (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.
What does a padding collator do?
Pads examples into compatible batch shapes and supplies masks.
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 |

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
Quick check
What makes a dataset training-ready?
An explicit task, checked examples, honest splits and compatible preprocessing.
What should remain consistent across model comparisons?
The declared data protocol and ordered evaluation examples.
What does a padding collator do?
Pads examples into compatible batch shapes and supplies masks.
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.