Dataset preparation
The part that decides success: sourcing, formatting to the chat template, cleaning, dedup, and leakage-free splits. A few hundred clean examples beat tens of thousands of noisy ones.
Learning objectives
- Explain why data quality decides fine-tuning success more than any hyperparameter.
- Format examples with the model's chat template.
- Clean, deduplicate, and split a dataset correctly.
- Estimate how much data you actually need.
30-second recap
- When fine-tuning wins: Prompting and RAG change what the model sees; fine-tuning changes the model itself — worth it for consistent style, format or narrow skills, not for adding facts.
code/ft2-data/ in the course, with a README. Run the scripts or copy the configs directly.Data is the whole game essential
Here's the scene that plays out over and over. A team spends a week arguing about learning rate, rank, and epochs, kicks off a costly GPU run, and the tuned model comes back worse than the base one — more confident, more fluent, and more confidently wrong. They tweak the hyperparameters and run again. Same result. What they never opened was the dataset, where a quarter of the "answers" were truncated, a third were near-duplicates, and a handful taught the model the exact opposite of what they wanted. The knobs were never the problem. The data was.
The oldest law in machine learning is garbage in, garbage out, and fine-tuning obeys it more strictly than almost anything else you'll do. Think of it like teaching an apprentice by example: the apprentice doesn't argue with your demonstrations, doesn't sanity-check them, doesn't know which ones were sloppy. They copy exactly what you show them, warts and all. A fine-tune is that apprentice with a perfect memory and no judgement — every bad example you feed it is a lesson it dutifully learns. Show it ten contradictory answers to the same question and you don't get an average; you get a model that has learned to be inconsistent.
The single biggest predictor of a good fine-tune is data quality, not learning rate or rank. A few hundred clean, consistent, on-distribution examples beat tens of thousands of noisy ones. Most failed fine-tunes are data problems wearing a hyperparameter costume.
The most common mistake is treating data prep as the boring chore you rush through to get to the "real" work of training. It's backwards: the training run is the cheap, automatable part — a script you launch and walk away from — while the dataset is the one thing only you can get right, because only you know what "good" looks like for your task. Every hour you spend reading your own examples and fixing what's wrong buys you more than any hyperparameter sweep. That's why this whole chapter is a pipeline: source it, format it to the model's template, clean and dedup it, then split it so your evaluation stays honest. Get that order right and the model almost tunes itself; get it wrong and no learning rate will save you. The diagram below is the assembly line every serious dataset walks through, in the one order that works.
This is the assembly line every training dataset goes through, left to right. Each box is a stage, and the arrows show the order — you can't skip ahead.
- Raw examples (left) is your source: whatever question→answer pairs you collected, in whatever messy shape they arrived.
- Format (chat template) turns each example into the exact role turns (user says X, assistant says Y) that the model expects — Lab FT2.1 does this.
- Clean + dedup is the quality stage: throw out broken examples and remove duplicates so the same thing isn't counted twice.
- Train / val split (right) divides the data into a part to learn from and a held-back part to test on — the no leakage label means those two parts must never share examples.
In short: Data flows one way: raw → format → clean → split. Getting this pipeline right matters more than any training setting — clean data is the whole game.
Format with the chat template essential
This is the section where the most fine-tunes quietly die — and the failure leaves no error message. Your data is clean, your training loss drops beautifully, the run completes, and the model comes back subtly broken: rambling, ignoring instructions, or refusing to stop talking. Nine times out of ten the cause is here, in formatting, because you hand-built the prompt structure and got a character wrong that you never even saw.
To understand why the exact format matters so much, picture how an instruction model actually learned to behave. It wasn't shown plain text — it was shown millions of conversations wrapped in a rigid chat template: invisible special tokens that mark "here begins a user turn," "here begins the assistant's reply," "the turn ends here." Those markers are the model's grammar of conversation. It learned that right after the assistant-start token, it's its turn to speak; it learned to stop at the end token. The template is the punctuation of a language the model speaks fluently — and if you feed it sentences with the wrong punctuation, it gets confused in ways that look like the model being dumb but are really you speaking broken grammar.
The common mistake — and it's seductive. You look at what the template produces, see something like [INST] question [/INST] answer, and think "I can just write that myself with an f-string." Then you get a space wrong, or use the wrong bracket style, or forget the leading beginning-of-sequence token, or use a different model's format entirely. Every one of those is a silent mismatch: the tokens you emit don't line up with the tokens the model was trained on, so it's now learning from examples written in a dialect it half-recognizes. Nothing crashes. The loss still goes down. But you've taught it noise. The analogy: it's like training a translator on sentences where the quotation marks are randomly wrong — they'll learn something, but not the clean signal you meant.
The fix is refreshingly simple: never write the format by hand. Every tokenizer ships with the exact template its model was trained on, and one function call, apply_chat_template, stamps your messages into it perfectly — right tokens, right spacing, right order, every time. You hand it a plain list of role/content turns and it hands back the model's native format. Below, the lab does exactly that; the rule to burn in is that the tokenizer already knows the format, so your job is only to give it clean turns and let it do the stamping.
Instruction models expect a specific structure of role turns. Use the tokenizer's apply_chat_template so your data matches exactly what the model saw in training — a common silent bug is hand-formatting prompts that don't match the template.
[{role, content}] turns become the model's exact native tokens through apply_chat_template (top path); hand-writing the format (bottom path) silently mismatches one token and mistrains the model.
Read the two paths left-to-right: both start from the same plain turns on the left, but only the top one arrives at a correct format.
- The left blue box is your input — a plain list of
{role, content}turns, no special tokens yet. - The top path sends those turns through the purple
apply_chat_templatebox, which stamps in the model's exact special tokens and spacing → the green native format the model was trained on. - The bottom red path is the seductive shortcut: hand-writing the format with an f-string. One wrong bracket, space, or missing token lands you in the red box — a silent mismatch that mistrains the model with no error.
In short: Same input, two roads — the tokenizer's road always emits the right tokens; the hand-built road only has to be wrong once to poison the run.
format.pyfrom transformers import AutoTokenizer
from datasets import load_dataset
tok = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3")
def to_text(ex):
msgs = [
{"role": "user", "content": ex["question"]},
{"role": "assistant", "content": ex["answer"]},
]
# exactly the format the model was trained on:
return {"text": tok.apply_chat_template(msgs, tokenize=False)}
ds = load_dataset("json", data_files="raw.jsonl", split="train").map(to_text)
print(ds[0]["text"][:300])
A model was trained to expect messages in one very specific layout. This script takes your plain question/answer pairs and rewrites them into that exact layout, so the model recognizes them.
AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3")downloads the tokenizer for a specific model — it knows the precise chat format that model was trained with.to_text(ex)is a small function run on each example. It builds amsgslist of two turns: auserturn holdingex["question"]and anassistantturn holdingex["answer"].tok.apply_chat_template(msgs, tokenize=False)is the key line: it stamps those turns into the model's official format and returns it as text (tokenize=Falsemeans "give me readable text, not number IDs").load_dataset(...).map(to_text)loads every row fromraw.jsonland runsto_texton each one, adding the new formatted"text"field.
What the output means: print(ds[0]["text"][:300]) shows the first 300 characters of one formatted example — you'll see the special role markers the model expects wrapped around your Q and A.
Try this: Change [:300] to [:800] to see a whole formatted example. Compare it to your raw question/answer — those special tokens around the text are exactly what hand-formatting usually gets wrong.
Clean, dedup, split intermediate
Formatting made your data the right shape; this step decides whether it's telling the truth. A dataset can be perfectly formatted and still poison a run — full of half-finished answers, the same example copied forty times, or a validation set that secretly overlaps with training. These aren't exotic edge cases; they're what real collected data looks like before anyone cleans it. This section is the quality gate, and skipping it is the difference between an eval score you can trust and one that lies to your face.
Take each job in turn, because each fixes a distinct failure. Cleaning is throwing out the examples that teach the wrong thing — a truncated answer teaches the model to stop mid-sentence; an off-task row teaches it to wander. Remember the apprentice: it copies whatever you show it, so a broken demonstration is a broken lesson, not a harmless one it ignores. Dedup matters because duplicates aren't free — they're a thumb on the scale. If one question appears fifty times, the model treats that pattern as fifty times more important than a question it saw once, quietly skewing what it learns toward whatever your scraper happened to over-collect. Most duplicates in real data aren't identical either; they differ only in casing or stray whitespace, which is why you compare a normalized form, not the raw string.
The most dangerous mistake lives in the split, and it has a name: leakage. You hold back a slice of data to measure how well the model generalizes to things it hasn't seen — that's the entire point of a validation set. But if a training example (or a near-duplicate of one) sneaks into that held-back slice, you're no longer measuring generalization; you're measuring memorization. The model saw the answer during training, so of course it aces the "test." Your eval lights up green, you ship with confidence, and production falls flat because the real world doesn't hand you questions you've already memorized. That's why the order is non-negotiable: dedup first, then split — and split on a fixed seed so the exact same partition happens every run and your results are reproducible. Do it the other way around and a duplicate can land on both sides of the wall, and the leak is invisible until it costs you. The checklist below is that order made concrete; treat it as a sequence, not a menu.
A' is gone before the split, so train and val never share an example; split first (bottom) and A' lands in val while A is in train — the model memorized the answer, so the eval lies.
Compare the two rows top-to-bottom: both start from the same raw set A A' B C where A' is a near-duplicate of A. Only the order of the steps differs.
- The left grey box in each row is the raw data — note it contains both
Aand its near-twinA'. - The top (green) path deduplicates first:
A'is dropped, leavingA B C, so the train/val split can only put each example on one side — the sets are disjoint and the eval is honest. - The bottom (red) path splits first:
Afalls into train andA'into val. BecauseA' ≈ A, the model already saw that answer in training — the green "test" score is measuring memorization, not generalization. That is leakage.
In short: Same data, two orders — dedup→split keeps train and val truly separate; split→dedup lets a twin slip across the wall and fakes a good score.
A dataset-prep checklist
- Clean: remove truncated, empty, or off-task examples; fix inconsistent formatting.
- Dedup: near-duplicate examples inflate size without adding signal (and leak into val).
- Balance: if it's classification, check class balance; over-represented classes bias the model.
- Split: hold out a validation set BEFORE training — and make sure no example leaks across.
prep.pyfrom datasets import load_dataset
import hashlib
ds = load_dataset("json", data_files="raw.jsonl", split="train")
seen, keep = set(), []
for ex in ds:
h = hashlib.md5(ex["question"].strip().lower().encode()).hexdigest()
if h not in seen:
seen.add(h); keep.append(ex)
ds = ds.select([i for i, ex in enumerate(ds)
if hashlib.md5(ex["question"].strip().lower().encode()).hexdigest() in seen])
split = ds.train_test_split(test_size=0.1, seed=42) # val holdout, fixed seed
print("train:", len(split["train"]), "val:", len(split["test"]))
This script does two cleanup jobs that quietly decide whether your evaluation later tells the truth: it removes duplicate examples, then splits the data into a training set and a held-back validation set.
seenis a set of fingerprints andkeepcollects the unique rows. For each example,hashlib.md5(ex["question"].strip().lower().encode()).hexdigest()makes a short fingerprint of the question (lower-cased and trimmed) so near-identical questions get the same fingerprint.if h not in seen:keeps a row only the first time its fingerprint appears — that's how duplicates get dropped.ds.train_test_split(test_size=0.1, seed=42)splits off 10% as the validation ("test") set.seed=42fixes the randomness so you get the same split every run — that's what makes results reproducible.
What the output means: print("train:", ..., "val:", ...) prints the two sizes, e.g. train: 450 val: 50 — most rows for learning, a small slice held back to test on.
Try this: Change test_size=0.1 to 0.2 and re-run — the val count grows and the train count shrinks. Deduping before this split is what stops the same example landing in both sides and faking a good score.
How much data? advanced
"How much data do I need?" is the first question everyone asks, and it's almost always the wrong one. The instinct — inherited from the old days of training models from scratch, where more data was nearly always better — is to assume you need tens of thousands of examples and to feel stuck until you have them. For fine-tuning an already-capable model, that instinct will send you collecting for weeks when you could have shipped in an afternoon. The base model already knows the language, the facts, the reasoning; you are not teaching it to think. You're teaching it a style, a format, or a narrow skill — and nudging a model that already knows almost everything takes far less than you'd guess.
Here's the mental model that reframes it. Fine-tuning isn't filling an empty bucket; it's showing a fluent expert how you'd like them to behave in one specific situation. To get a brilliant colleague to always answer support tickets in your house tone, you don't hand them a library — you show them a few dozen good examples and they get it. The same holds here: for style or format tuning, hundreds of clean examples often move the needle decisively; for a genuinely narrow new skill, low thousands. What matters far more than raw count is that the examples are consistent (they don't contradict each other) and on-distribution (they look like the inputs you'll actually see in production). Ten thousand inconsistent examples teach the model to be inconsistent; three hundred coherent ones teach it one clear behavior.
The mistake this section exists to prevent is "we need more data" when the honest diagnosis is "we need cleaner data." When a first run underperforms, adding rows is the tempting reflex — but if a third of your set is duplicates and another third is noise, doubling the pile just doubles the mess. Count only your usable examples (raw count discounted by your duplicate and noise rates) and the gap to "enough" often closes without collecting a single new row. So the discipline is the opposite of the reflex: start small, tune, then measure against a real held-out eval (that's FT6), and let the evals — not your anxiety — tell you whether the bottleneck is quantity or quality. Add data only when the measurement demands it. Below is the rule of thumb, but keep the reframe in front of it: consistency beats volume, and cleaning beats collecting.
For style/format tuning, hundreds of high-quality examples often suffice. For a narrow skill, low thousands. More matters less than consistent and on-distribution. Start small, measure (FT6), and add data only if evals say you need it.
Compare the two columns left-to-right; each shows a pile of data and what survives once you count only the usable rows.
- The left column is a big raw pile (10,000). The row of small boxes splits it into red dups and noise that teach nothing (or the wrong thing) and a green usable slice — after discounting, only ~3,300 rows actually help.
- The right column is a small pile (400) that is already clean: low duplicate and noise rates, so ~360 rows are usable and, crucially, consistent — they don't contradict each other.
- The payoff is the mono line under each: the usable count is what matters. A curated 360 can out-tune a messy 3,300, because consistency and on-distribution examples move the model, not raw volume.
In short: Multiply your raw count by (1 − dup rate) × (1 − noise rate) before you decide you need more — the gap to "enough" often closes by cleaning, not collecting.
Exercise FT2.1 — Build a clean dataset
Context: Nothing teaches data quality like running the whole pipeline on your own examples and eyeballing the output — if a formatted example looks wrong to you, it looks wrong to the model.
Your task: Take 200–500 examples for a task you care about, format them with the chat template, dedup, and split 90/10, then inspect ten random formatted examples by eye.
Requirements:
- Use 200–500 real examples
- Format with the model's chat template
- Dedup, then split 90/10 without leakage
- Manually inspect ten random formatted examples
- Fix anything that reads wrong before you'd train on it
💡 Hint: The eyeball pass is the point — run the full format → dedup → split pipeline from the earlier rungs, then read the output like a reviewer.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Instruction models expect role-tagged turns, and getting that message shape wrong is the single most common silent fine-tuning bug.
Your task: Without any libraries, transform raw question/answer pairs into the [{role, content}] message shape a tokenizer's chat template consumes.
Requirements:
- Map each
{question, answer}to a user turn then an assistant turn - Produce a two-element list of
{role, content}dicts - Keep it pure Python — no libraries
- Frame this as the pre-template step
apply_chat_templateconsumes
💡 Hint: This is deliberately upstream of the tokenizer — you're producing the message list, not the final templated string.
Show solution
The pre-template step, in plain Python:
raw = [
{"question": "Reset my password?", "answer": "Go to Settings > Security."},
{"question": "Enable 2FA?", "answer": "Same page, toggle 2FA."},
]
def to_messages(ex):
return [{"role": "user", "content": ex["question"]},
{"role": "assistant", "content": ex["answer"]}]
for ex in raw:
print(to_messages(ex))
# [{'role': 'user', 'content': 'Reset my password?'}, {'role': 'assistant', ...}]
This message list is exactly what tokenizer.apply_chat_template turns into the model's training format. Matching that format is the single most common silent bug to avoid.
Context: Duplicates make the model overweight repeated examples, and most duplicates in scraped data differ only in casing or whitespace.
Your task: Write a deduper that drops exact duplicates and simple near-duplicates (same normalized text) and reports how many were removed.
Requirements:
- Normalize by lowercasing and collapsing runs of whitespace
- Key a
seenset on the normalized (question, answer) pair - Keep the first occurrence of each
- Report kept-of-total counts
- Target casing/whitespace variants, the common scraped-data case
💡 Hint: Normalize first, then dedup on the normalized key — the raw text still gets kept, you just compare on a canonical form.
Show solution
Normalize, then key on the normalized form:
import re
def norm(s):
return re.sub(r"\s+", " ", s.strip().lower())
def dedup(examples):
seen, kept = set(), []
for ex in examples:
key = (norm(ex["question"]), norm(ex["answer"]))
if key not in seen:
seen.add(key); kept.append(ex)
return kept
data = [
{"question":"Reset password?","answer":"Settings > Security."},
{"question":"reset password?","answer":"settings > security."}, # near-dup
{"question":"Enable 2FA?","answer":"Toggle 2FA."},
]
kept = dedup(data)
print(f"kept {len(kept)} of {len(data)} ({len(data)-len(kept)} removed)") # kept 2 of 3
Casing/whitespace variants are the most common duplicates in scraped data. Removing them stops the model from silently over-training on the same content.
Context: A train/val split must never share examples, or your validation score measures memorization instead of generalization.
Your task: Write a deterministic split that also guards against leakage: an example whose question already appears in train cannot land in val.
Requirements:
- Assign each example deterministically by hashing its text into a 0–99 bucket
- Compare the bucket to
val_fracso runs are reproducible - Track seen questions and demote a would-be-val example to train if its question already appeared
- Verify the final train/val overlap is empty
💡 Hint: Hashing gives reproducibility; the extra seen-question guard is what turns a random split into a leakage-free one.
Show solution
Deterministic hashing split plus a leakage guard:
import hashlib
def bucket(text, val_frac=0.2):
h = int(hashlib.md5(text.encode()).hexdigest(), 16)
return "val" if (h % 100) < val_frac*100 else "train"
def split(examples, val_frac=0.2):
train, val, seen_q = [], [], set()
for ex in examples:
q = ex["question"].strip().lower()
where = bucket(q, val_frac)
if where == "val" and q in seen_q: # would leak -> keep in train
where = "train"
(val if where == "val" else train).append(ex)
seen_q.add(q)
return train, val
data = [{"question":f"q{i}","answer":f"a{i}"} for i in range(10)]
tr, va = split(data)
print(f"train={len(tr)} val={len(va)} overlap={set(e['question'] for e in tr) & set(e['question'] for e in va)}")
# overlap=set() -- no leakage
Hashing makes the split reproducible across runs; the guard ensures a val example never duplicates a train question, so your val score reflects generalization, not memorization.
Context: A few hundred clean examples often beat tens of thousands of noisy ones, so “we need more data” is frequently really “we need cleaner data”.
Your task: Write an estimator that recommends a dataset size from task narrowness and desired quality, and flags when the real fix is cleaning rather than collecting.
Requirements:
- Pick a base target by narrowness (very narrow < narrow < broad)
- Compute usable count as raw × (1 − dup rate) × (1 − noise rate)
- Return ENOUGH-but-clean vs collect-to-target
- Separate usable examples from raw counts to push cleaning first
💡 Hint: Discount the raw count by the dup and noise rates before comparing to the target — often the gap closes once you count only usable rows.
Show solution
A rule-of-thumb estimator that favors quality:
def recommend_size(task_narrowness, current_n, current_dup_rate, current_noise_rate):
base = {"very narrow": 300, "narrow": 800, "broad": 3000}[task_narrowness]
clean_n = current_n * (1 - current_dup_rate) * (1 - current_noise_rate)
if clean_n >= base:
return f"ENOUGH clean examples (~{int(clean_n)} of {current_n}); CLEAN, don't collect"
return f"collect to ~{base} clean; you have ~{int(clean_n)} usable now"
print(recommend_size("narrow", current_n=5000, current_dup_rate=0.3, current_noise_rate=0.4))
# ENOUGH clean examples (~2100 of 5000); CLEAN, don't collect
print(recommend_size("narrow", current_n=400, current_dup_rate=0.05, current_noise_rate=0.05))
# collect to ~800 clean; you have ~361 usable now
Often you already have enough raw rows — the fix is dedup + noise removal, not more collection. The estimator separates "usable" from "raw" so you clean before you scrape.
Context: The format step must match exactly what the model saw in pretraining, which is why you never hand-write special tokens — you call the model's own chat template.
Your task: Show the lesson's real format step using transformers/datasets so it matches the model's template exactly, labelling it as needing the libraries.
Requirements:
- Load the tokenizer with
AutoTokenizer.from_pretrained - Build user/assistant messages and format via
apply_chat_template(msgs, tokenize=False) - Load JSONL with
load_datasetand.mapthe formatter - Never hand-format special tokens
- Note the code needs
pip install transformers datasets
💡 Hint: The tokenizer already carries the model's template — let apply_chat_template emit the tokens so you can't get them wrong.
Show solution
The real formatting pass — needs pip install transformers datasets:
from transformers import AutoTokenizer
from datasets import load_dataset
tok = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3")
def to_text(ex):
msgs = [{"role": "user", "content": ex["question"]},
{"role": "assistant", "content": ex["answer"]}]
# apply_chat_template emits EXACTLY the format the model was trained on:
return {"text": tok.apply_chat_template(msgs, tokenize=False)}
ds = load_dataset("json", data_files="raw.jsonl", split="train").map(to_text)
print(ds[0]["text"][:300])
Never hand-format the special tokens — apply_chat_template uses the model's own template, which is the difference between a working tune and a silently mistrained one.
Context: Most failed fine-tunes are data problems in disguise, so a cheap pre-flight audit pays for itself before you commit any GPU hours.
Your task: Before an expensive run, write a data audit that reports count, exact-dup rate, empty/short answers, and label imbalance, then gates the run on quality thresholds and prints PASS/FAIL with reasons.
Requirements:
- Compute example count and dup rate from a normalized (question, answer) set
- Count short answers (e.g. fewer than two words)
- Assemble a report dict of the rates
- Gate: FAIL on high dup rate, high short-answer rate, or too few examples
- Print PASS/FAIL joined with the specific failing reasons
💡 Hint: Each threshold breach becomes a reason string; PASS is simply the case where the reason list came back empty.
Show solution
A pre-flight audit that gates the training run:
import re
def audit(examples):
n = len(examples)
norm = lambda s: re.sub(r"\s+"," ",s.strip().lower())
dups = n - len({(norm(e['question']), norm(e['answer'])) for e in examples})
short = sum(1 for e in examples if len(e['answer'].split()) < 2)
report = {"n": n, "dup_rate": round(dups/n,2), "short_rate": round(short/n,2)}
problems = []
if report["dup_rate"] > 0.10: problems.append("too many duplicates")
if report["short_rate"] > 0.05: problems.append("too many empty/short answers")
if n < 200: problems.append("dataset likely too small")
report["gate"] = "PASS" if not problems else "FAIL: " + "; ".join(problems)
return report
data = [{"question":f"q{i%40}","answer":"ok"} for i in range(300)]
print(audit(data)) # high dup_rate + short answers -> FAIL
Most failed fine-tunes are data problems in disguise. A cheap audit that blocks the run on dup/short/size thresholds saves the GPU hours a bad dataset would waste.
✓ Checkpoint — you can move on when you can…
- Explain why data quality outweighs hyperparameters.
- Format examples with
apply_chat_template. - Clean, dedup, and split without leakage.
- Estimate the data volume a task needs.
Knowledge check check yourself
Why must you format training examples with the tokenizer's apply_chat_template rather than hand-writing the prompt structure?
Show answer
Why must deduplication happen before the train/validation split, and what goes wrong if it doesn't?