teachyou.ai academy
← All posts
Fine-TuningdatasetJSONLdata qualityLLM training

Preparing a Fine-Tuning Dataset

Pramod Dutta · Jul 3, 2026 · 12 min read

A fine-tuning dataset is a set of input/output examples that teach a base model the exact behavior you want, and preparing it well matters more than any hyperparameter you will touch. Ninety percent of a good fine-tune is the fine-tuning dataset: its format, its diversity, its label quality, and how honestly you split it for evaluation. This guide walks through the whole pipeline with runnable Python and real shell commands so you can build a fine-tuning dataset that trains cleanly instead of teaching your model your own bugs.

What a fine-tuning dataset actually is

At the byte level a fine-tuning dataset is almost always JSONL: one JSON object per line, no wrapping array, no trailing commas. Each line is a complete training example. Whether you fine-tune on a hosted API or a local model with a framework like Axolotl or Unsloth, the two dominant shapes are the chat format and the raw prompt/completion format.

The chat format is what most current instruction-tuned models expect:

{"messages": [{"role": "system", "content": "You are a support agent for an Indian fintech app."}, {"role": "user", "content": "My UPI payment failed but money got debited."}, {"role": "assistant", "content": "That debit is almost always auto-reversed within 48 hours..."}]}

The older prompt/completion shape still shows up in local training configs:

{"prompt": "Classify the sentiment: 'The refund took three weeks.'\n\nSentiment:", "completion": " negative"}

Pick one shape and hold it constant across the entire fine-tuning dataset. Mixing formats in one file is the single most common reason a training job rejects your data. Your target model and trainer decide which shape is legal, so check that first, then never deviate.

Two rules that save hours later. First, the assistant message is the only thing the model learns to produce, so every token you want at inference time must live there and nowhere else. Second, keep the system prompt identical to what you will send in production. If you train with one system prompt and serve with another, you are evaluating a model you never actually built.

How many examples do you need

There is no universal number, but there are working floors. For a narrow task like classification or format conversion, a few hundred clean examples often beats a few thousand noisy ones. For style, tone, or a multi-step behavior, plan for low thousands. Beyond that, returns diminish fast unless the task is genuinely broad.

The honest heuristic: add examples until your held-out evaluation stops improving, not until you hit a round number. A fine-tuning dataset of 800 diverse, correctly labeled rows will outperform 8,000 rows scraped from a messy ticket export every time. Volume hides label noise; it does not cancel it.

Diversity is worth more than raw count. If 60 percent of your examples are variations of one intent, the model will overfit that intent and get worse everywhere else. Before you scale up, look at the distribution of what your examples actually cover.

Building the schema and a loader

Start by defining what one clean record looks like in code, then force every raw row through it. Here is a small validator using pydantic that rejects malformed examples loudly instead of letting them poison the run:

from pydantic import BaseModel, field_validator
from typing import Literal
import json

class Turn(BaseModel):
    role: Literal["system", "user", "assistant"]
    content: str

    @field_validator("content")
    @classmethod
    def non_empty(cls, v):
        if not v or not v.strip():
            raise ValueError("empty content")
        return v

class Example(BaseModel):
    messages: list[Turn]

    @field_validator("messages")
    @classmethod
    def shape_ok(cls, msgs):
        roles = [m.role for m in msgs]
        if roles[-1] != "assistant":
            raise ValueError("last turn must be assistant")
        if roles.count("assistant") == 0:
            raise ValueError("no assistant turn to learn from")
        return msgs

def load_clean(path):
    good, bad = [], []
    with open(path) as f:
        for i, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
                Example(**obj)
                good.append(obj)
            except Exception as e:
                bad.append((i, str(e)))
    print(f"kept {len(good)}, rejected {len(bad)}")
    for ln, err in bad[:20]:
        print(f"  line {ln}: {err}")
    return good

Run this before anything else. If it rejects 15 percent of your rows, you just found the reason a previous fine-tune underperformed. The rejects are not noise to ignore; they are a report on your extraction pipeline.

Cleaning the raw data

Real source data is dirty. A support export has HTML fragments, signature blocks, ticket IDs, and PII. A scraped corpus has boilerplate navigation and duplicate paragraphs. Cleaning is where a mediocre fine-tuning dataset becomes a good one.

Strip the artifacts that will otherwise be learned as patterns. If every support reply ends with "Regards, Team XYZ | Ticket #48213", the model will learn to append ticket numbers to answers. Remove it:

import re

def clean_reply(text):
    text = re.sub(r"Ticket\s*#\s*\d+", "", text)
    text = re.sub(r"(Regards|Thanks|Best),?\s*\n.*$", "", text, flags=re.DOTALL)
    text = re.sub(r"<[^>]+>", "", text)          # strip HTML tags
    text = re.sub(r"\n{3,}", "\n\n", text)         # collapse blank lines
    return text.strip()

Scrub PII before it ever reaches a training server. Emails, phone numbers, and card fragments do not belong in a fine-tuning dataset, and in many jurisdictions keeping them there is a compliance problem, not a style choice:

def redact(text):
    text = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[EMAIL]", text)
    text = re.sub(r"\b(?:\+91[- ]?)?[6-9]\d{9}\b", "[PHONE]", text)
    text = re.sub(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "[CARD]", text)
    return text

Redact consistently in both input and output. If you redact the user turn but leave a real phone number in the assistant turn, you have taught the model to emit real phone numbers.

Deduplication that actually works

Duplicates are worse than useless. They inflate your count, bias the model toward whatever is repeated, and leak between train and test if you split naively. Exact dedup is trivial, but the duplicates that hurt most are near-duplicates: the same question phrased two ways.

Exact first, using a hash of the normalized content:

import hashlib

def norm_key(ex):
    joined = " ".join(m["content"].lower().split() for m in ex["messages"]
                       if m["role"] != "system")
    return hashlib.sha256(joined.encode()).hexdigest()

def dedup_exact(rows):
    seen, out = set(), []
    for ex in rows:
        k = norm_key(ex)
        if k not in seen:
            seen.add(k)
            out.append(ex)
    return out

For near-duplicates, MinHash with a library like datasketch scales to large sets without an all-pairs comparison. A cheaper approximation that works for tens of thousands of rows is shingling plus a Jaccard threshold on the user turn. Whatever you use, remove near-duplicates before splitting, otherwise a paraphrase of a training example ends up in your test set and your metrics lie to you.

One caution: deliberate repetition of rare-but-critical cases is fine. If you have three examples of a dangerous edge case, do not dedup them down to one just because they are similar. Dedup for accidental redundancy, not for intentional emphasis.

Splitting train, validation, and test

Split by a stable key, not randomly per row, and never after augmentation. If one underlying conversation produced five augmented variants, all five must land in the same split or you leak. Group-aware splitting prevents the most flattering and most misleading kind of eval result.

import random

def grouped_split(rows, key_fn, ratios=(0.9, 0.05, 0.05), seed=13):
    groups = {}
    for ex in rows:
        groups.setdefault(key_fn(ex), []).append(ex)
    keys = list(groups)
    random.Random(seed).shuffle(keys)
    n = len(keys)
    a = int(n * ratios[0])
    b = int(n * (ratios[0] + ratios[1]))
    train = [ex for k in keys[:a] for ex in groups[k]]
    val   = [ex for k in keys[a:b] for ex in groups[k]]
    test  = [ex for k in keys[b:] for ex in groups[k]]
    return train, val, test

Keep the test set small, clean, and frozen. It is your only honest read on whether the fine-tune worked. Do not look at individual test examples while iterating, or you will start unconsciously tuning to them. The validation set is for during-training signal; the test set is touched once, at the end.

Balancing and label quality

Look at your label distribution before you train. For a classifier, a fine-tuning dataset that is 80 percent one class will produce a model that loves that class. You have two levers: downsample the majority or upsample the minority (with real, not copied, examples where possible). Downsampling is usually safer because duplicating minority rows reintroduces the leakage problem.

from collections import Counter

def label_report(rows, label_fn):
    counts = Counter(label_fn(ex) for ex in rows)
    total = sum(counts.values())
    for label, c in counts.most_common():
        print(f"{label:20s} {c:6d}  {c/total:6.1%}")

Label quality is the ceiling on model quality. A model cannot be more correct than its training labels. Spend real time reviewing a random sample of assistant turns by hand. If you generated labels with a stronger model (a legitimate and common technique), you still have to spot-check, because a synthetic fine-tuning dataset inherits the generator's blind spots and its confident mistakes.

Token budget and truncation

Every example has a token length, and examples longer than the model's training context get truncated, usually from one end. Silent truncation is dangerous: it can chop off exactly the assistant answer you wanted to teach. Measure lengths before training.

# approximate token check with tiktoken-style counting
def length_report(rows, encode):
    lengths = sorted(len(encode(json.dumps(ex))) for ex in rows)
    n = len(lengths)
    p = lambda q: lengths[int(n * q)]
    print(f"min {lengths[0]}  p50 {p(0.5)}  p95 {p(0.95)}  max {lengths[-1]}")

If your p95 length is near the context limit, either raise the training sequence length (costs memory) or split long examples into smaller coherent ones. Do not let the trainer truncate blindly. A row whose assistant turn got cut in half teaches the model to stop mid-sentence.

Validating before you spend money

Before you kick off a job that costs compute time, run a final gate that checks format, dedup between splits, and basic sanity. This one command-line style check catches the errors that waste a training run:

def final_gate(train, val, test):
    tr_keys = {norm_key(e) for e in train}
    leaks = [e for e in test if norm_key(e) in tr_keys]
    assert not leaks, f"{len(leaks)} test rows leak into train"
    for name, split in [("train", train), ("val", val), ("test", test)]:
        assert split, f"{name} split is empty"
    print("gate passed: no leakage, all splits populated")

If you fine-tune through a hosted API, most providers offer a validation or dry-run step that reports format errors and token counts before charging you. Use it every time. On a local stack, load a handful of rows through the actual tokenizer and chat template your trainer will use and print the rendered string. Seeing the exact text the model will train on, special tokens and all, catches template mismatches that no schema check will.

Write the final splits back out as clean JSONL and checksum them so you know exactly which fine-tuning dataset produced which model:

import json, hashlib

def write_jsonl(rows, path):
    with open(path, "w") as f:
        for ex in rows:
            f.write(json.dumps(ex, ensure_ascii=False) + "\n")
    h = hashlib.sha256(open(path, "rb").read()).hexdigest()[:12]
    print(f"wrote {len(rows)} -> {path}  sha {h}")

Commit those checksums alongside your training config. When a fine-tune behaves oddly three weeks later, the first question is always "which data was this trained on," and a checksum answers it in seconds.

A realistic end-to-end order of operations

Put the pieces together in this order and the pipeline stays honest:

  1. Extract raw rows from source and immediately run the pydantic loader to quarantine malformed data.
  2. Clean artifacts and redact PII in both input and output turns.
  3. Deduplicate exact, then near-duplicates, before any splitting.
  4. Report the label distribution and rebalance if one class dominates.
  5. Run the token length report and fix or split anything near the context ceiling.
  6. Do a grouped split into train, validation, and test by a stable conversation key.
  7. Run the leakage gate, render a few rows through the real chat template, and hand-review a random sample.
  8. Write clean JSONL, checksum it, and only then start the job.

Skipping steps 3, 6, or 7 is how teams end up with a model that scores well on their own test set and disappoints in production. The gap is almost never the model architecture; it is a fine-tuning dataset that leaked, overfit one intent, or trained on truncated answers.

FAQ

What format should my fine-tuning dataset be in? JSONL with one example per line. Use the chat format (a messages array of system/user/assistant turns) for modern instruction-tuned models, or prompt/completion for older local training configs. Confirm which shape your target model and trainer require, then keep it identical across every row. Mixed formats are the top cause of rejected jobs.

How many examples do I need to fine-tune? Enough that a held-out test set stops improving. Narrow tasks like classification can work with a few hundred clean examples; style and multi-step behaviors usually need low thousands. Diversity and label accuracy beat raw volume: 800 correct, varied rows outperform 8,000 noisy ones.

Can I use a stronger model to generate my fine-tuning dataset? Yes, generating training examples with a more capable model is common and effective. But a synthetic dataset inherits the generator's blind spots, so you must hand-review a random sample and spot-check labels. Never ship synthetic data straight into a training run without human verification.

Why does deduplication matter so much? Duplicates inflate your count, bias the model toward repeated content, and, worst of all, leak between train and test so your evaluation looks better than the model really is. Remove exact and near-duplicates before splitting. Keep deliberate repetition of rare critical cases; only remove accidental redundancy.

How do I stop data from leaking between train and test? Split by a stable group key such as the source conversation ID, and split before any augmentation so all variants of one example land in the same split. Then run an explicit gate that checks no test example's content hash appears in train. Random per-row splitting after augmentation is the classic leak.

What is the most common mistake in preparing a fine-tuning dataset? Putting content the model should learn anywhere except the assistant turn, or letting long examples get silently truncated so the assistant answer is cut off. Both teach the wrong thing. Measure token lengths, render rows through the real chat template, and confirm the assistant turn is complete before training.

Preparing a Fine-Tuning Dataset · TeachYou Academy